diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 3795e4bb5..62308874d 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -1691,6 +1691,69 @@ } } }, + "/admin/channels/{channel_id}": { + "delete": { + "operationId": "delete_admin_thread_channel", + "summary": "Delete a thread", + "tags": ["Admin"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Deletes a thread channel with its messages and memberships. Only public and private threads can be deleted here. Requires MESSAGE_DELETE_ALL permission.", + "x-fluxer-experiment": "channel_threads", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "description": "The ID of the channel", + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "description": "The ID of the channel" + } + ] + } + }, "/admin/channels/{channel_id}/messages": { "get": { "operationId": "list_admin_channel_messages", @@ -3262,7 +3325,7 @@ "required": false, "schema": { "description": "Filter entries by the type of action", - "allOf": [{"$ref": "#/components/schemas/AuditLogActionTypeInput"}] + "allOf": [{"$ref": "#/components/schemas/AuditLogActionTypeFilter"}] }, "description": "Filter entries by the type of action" } @@ -3804,6 +3867,69 @@ ] } }, + "/admin/guilds/{guild_id}/threads": { + "get": { + "operationId": "list_admin_guild_threads", + "summary": "List guild threads", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ListGuildThreadsResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Lists every thread of a guild, active and archived, whether or not the channel threads experiment is active for it. Requires GUILD_LOOKUP permission.", + "x-fluxer-experiment": "channel_threads", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the guild", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "description": "The ID of the guild" + } + ] + } + }, "/admin/instance/config": { "get": { "operationId": "get_admin_instance_config", @@ -5336,7 +5462,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages and guild_members indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.", + "description": "Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages, guild_members and threads indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -5353,7 +5479,8 @@ "channel_messages", "guild_members", "favorite_memes", - "discovery" + "discovery", + "threads" ], "x-enumDescriptions": [ "Guild search index", @@ -5363,7 +5490,8 @@ "Channel message search index", "Guild member search index", "Favourite meme search index", - "Discovery guild search index" + "Discovery guild search index", + "Thread search index (channel_threads experiment)" ], "enum": [ "guilds", @@ -5373,7 +5501,8 @@ "channel_messages", "guild_members", "favorite_memes", - "discovery" + "discovery", + "threads" ], "type": "string" }, @@ -10574,6 +10703,7 @@ "domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"}, "plutonium_page": {"$ref": "#/components/schemas/PlutoniumPageConfigResponse"}, "captcha": {"$ref": "#/components/schemas/CaptchaConfigResponse"}, + "channel_threads": {"$ref": "#/components/schemas/ChannelThreadsConfigResponse"}, "experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"}, "registration": { "type": "object", @@ -10988,6 +11118,7 @@ "domain_migration", "plutonium_page", "captcha", + "channel_threads", "experiment_delivery", "registration", "self_hosted", @@ -11130,6 +11261,10 @@ "allOf": [{"$ref": "#/components/schemas/PlutoniumPageConfigUpdateRequest"}] }, "captcha": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/CaptchaConfigUpdateRequest"}]}, + "channel_threads": { + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/ChannelThreadsConfigUpdateRequest"}] + }, "experiment_delivery": { "nullable": true, "allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}] @@ -11329,6 +11464,18 @@ "billing": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/InstanceBillingUpdateRequest"}]} } }, + "ListGuildThreadsResponse": { + "type": "object", + "properties": { + "threads": { + "type": "array", + "items": {"$ref": "#/components/schemas/ThreadChannelResponse"}, + "description": "Every thread of the guild, active and archived, newest first" + } + }, + "required": ["threads"], + "additionalProperties": false + }, "ListGuildStickersResponse": { "type": "object", "properties": { @@ -11419,7 +11566,7 @@ } } }, - "AuditLogActionTypeInput": { + "AuditLogActionTypeFilter": { "description": "The type of action that occurred", "type": "integer", "minimum": 0, @@ -11522,6 +11669,11 @@ "type": "array", "items": {"$ref": "#/components/schemas/AuditLogWebhookResponse"}, "description": "Webhooks referenced in the audit log entries" + }, + "threads": { + "description": "Threads referenced in the audit log entries, when the viewer can see threads", + "type": "array", + "items": {"allOf": [{"$ref": "#/components/schemas/ThreadChannelResponse"}]} } }, "required": ["audit_log_entries", "users", "webhooks"], @@ -13070,7 +13222,7 @@ "ChannelType": { "description": "The type of the channel", "type": "integer", - "enum": [0, 1, 2, 3, 4, 5, 998, 999], + "enum": [0, 1, 2, 3, 4, 5, 10, 11, 12, 15, 16, 998, 999], "format": "int32", "x-enumNames": [ "GUILD_TEXT", @@ -13079,6 +13231,11 @@ "GROUP_DM", "GUILD_CATEGORY", "GUILD_ANNOUNCEMENT", + "ANNOUNCEMENT_THREAD", + "PUBLIC_THREAD", + "PRIVATE_THREAD", + "GUILD_FORUM", + "GUILD_MEDIA", "GUILD_LINK", "DM_PERSONAL_NOTES" ], @@ -13089,6 +13246,11 @@ "A group direct message between users", "A category that contains channels", "A guild channel whose messages can be published to channels that follow it", + "A public thread within an announcement channel", + "A public thread within a text, forum or media channel", + "A private thread within a text channel", + "A channel that can only contain threads", + "A channel that can only contain threads in a gallery view", "A link channel for external resources", "Personal notes DM channel" ] @@ -13531,7 +13693,7 @@ "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] }, "message_id": { - "description": "The ID of the referenced message, absent on a channel follow system message", + "description": "The ID of the referenced message, absent on a channel follow system message and on thread created messages that reference only a thread", "nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] }, @@ -13577,6 +13739,10 @@ "required": ["participants"], "additionalProperties": false }, + "thread": { + "description": "The thread started from this message, when the viewer can see threads", + "allOf": [{"$ref": "#/components/schemas/ThreadChannelResponse"}] + }, "referenced_message": { "description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message has no default reference. Clients must tell null apart from absent by key presence.", "nullable": true, @@ -13677,7 +13843,7 @@ "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] }, "message_id": { - "description": "The ID of the referenced message, absent on a channel follow system message", + "description": "The ID of the referenced message, absent on a channel follow system message and on thread created messages that reference only a thread", "nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] }, @@ -13722,6 +13888,10 @@ }, "required": ["participants"], "additionalProperties": false + }, + "thread": { + "description": "The thread started from this message, when the viewer can see threads", + "allOf": [{"$ref": "#/components/schemas/ThreadChannelResponse"}] } }, "required": [ @@ -13874,6 +14044,218 @@ "x-enumNames": ["SAFE", "AGE_RESTRICTED"], "x-enumDescriptions": ["Guild is safe for all ages", "Guild is age-restricted"] }, + "ThreadChannelResponse": { + "type": "object", + "properties": { + "id": { + "description": "The unique identifier (snowflake) for this channel", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "guild_id": { + "description": "The ID of the guild this channel belongs to", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "name": {"description": "The name of the channel", "nullable": true, "type": "string"}, + "topic": {"description": "The topic of the channel", "nullable": true, "type": "string"}, + "url": { + "description": "The URL associated with the channel", + "nullable": true, + "type": "string", + "format": "uri" + }, + "icon": {"description": "The icon hash of the channel (for group DMs)", "nullable": true, "type": "string"}, + "owner_id": { + "description": "The ID of the owner of the channel (for group DMs)", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "type": { + "description": "The type of the channel", + "type": "integer", + "enum": [0, 1, 2, 3, 4, 5, 10, 11, 12, 15, 16, 998, 999], + "format": "int32", + "x-enumNames": [ + "GUILD_TEXT", + "DM", + "GUILD_VOICE", + "GROUP_DM", + "GUILD_CATEGORY", + "GUILD_ANNOUNCEMENT", + "ANNOUNCEMENT_THREAD", + "PUBLIC_THREAD", + "PRIVATE_THREAD", + "GUILD_FORUM", + "GUILD_MEDIA", + "GUILD_LINK", + "DM_PERSONAL_NOTES" + ], + "x-enumDescriptions": [ + "A text channel within a guild", + "A direct message between users", + "A voice channel within a guild", + "A group direct message between users", + "A category that contains channels", + "A guild channel whose messages can be published to channels that follow it", + "A public thread within an announcement channel", + "A public thread within a text, forum or media channel", + "A private thread within a text channel", + "A channel that can only contain threads", + "A channel that can only contain threads in a gallery view", + "A link channel for external resources", + "Personal notes DM channel" + ] + }, + "position": { + "description": "The sorting position of the channel", + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "parent_id": { + "description": "The ID of the parent category for this channel", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "bitrate": { + "description": "The bitrate of the voice channel in bits per second", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "user_limit": { + "description": "The maximum number of users allowed in the voice channel", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "voice_connection_limit": { + "description": "The maximum active voice connections allowed per user in the voice channel", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "rtc_region": { + "description": "The voice region ID for the voice channel", + "nullable": true, + "type": "string" + }, + "last_message_id": { + "description": "The ID of the last message sent in this channel", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "last_pin_timestamp": { + "description": "The ISO 8601 timestamp of when the last pinned message was pinned", + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "permission_overwrites": { + "description": "The permission overwrites for this channel", + "type": "array", + "items": {"$ref": "#/components/schemas/ChannelOverwriteResponse"} + }, + "recipients": { + "description": "The recipients of the DM channel", + "maxItems": 49, + "type": "array", + "items": {"allOf": [{"$ref": "#/components/schemas/UserPartialResponse"}]} + }, + "nsfw": { + "description": "Whether the channel is marked as NSFW (effective value, walking channel → category → guild)", + "type": "boolean" + }, + "nsfw_override": { + "description": "Per-channel adult-content override; null means inherit from parent category and then guild. Categories use this same field as their own override.", + "nullable": true, + "type": "boolean" + }, + "content_warning_level": { + "description": "Channel-level content warning override (0=inherit, 1=force-warn)", + "allOf": [{"$ref": "#/components/schemas/ContentWarningLevel"}] + }, + "content_warning_text": { + "description": "Custom channel content warning text (max 200 characters); null inherits from parent or guild", + "nullable": true, + "type": "string", + "maxLength": 200 + }, + "rate_limit_per_user": { + "description": "The slowmode rate limit in seconds", + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "nicks": { + "description": "Custom nicknames for users in this channel (for group DMs)", + "type": "object", + "additionalProperties": {"type": "string", "minLength": 1, "maxLength": 32} + }, + "flags": {"description": "Channel flags", "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, + "thread_metadata": { + "description": "Thread-specific fields", + "allOf": [{"$ref": "#/components/schemas/ThreadMetadataResponse"}] + }, + "applied_tags": { + "description": "IDs of the tags applied to a forum or media post", + "maxItems": 5, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeStringType"} + }, + "message_count": { + "description": "Messages in the thread, excluding the starter and deleted messages", + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "total_message_sent": { + "description": "Messages ever sent in the thread, never decremented", + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "member_count": { + "description": "Approximate number of thread members, capped at 50", + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "member_ids_preview": { + "description": "IDs of the most recently joined members of a forum or media post, newest first", + "maxItems": 8, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeStringType"} + }, + "member": { + "description": "The thread member object for the current user", + "allOf": [{"$ref": "#/components/schemas/ThreadMemberResponse"}] + }, + "default_auto_archive_duration": { + "description": "Default auto archive duration for new threads", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode copied onto new threads", + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "available_tags": { + "description": "Tags that can be applied to posts", + "maxItems": 20, + "type": "array", + "items": {"$ref": "#/components/schemas/ForumTagResponse"} + }, + "default_reaction_emoji": { + "description": "Default reaction for posts", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/DefaultReactionEmojiResponse"}] + }, + "default_sort_order": { + "description": "Default sort order for posts", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "default_forum_layout": { + "description": "Default layout for forum posts", + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "default_tag_setting": { + "description": "Default tag search setting", + "type": "string", + "enum": ["match_some", "match_all"] + } + }, + "required": ["id", "type"], + "additionalProperties": false + }, "MessageSnapshotResponse": { "type": "object", "properties": { @@ -14210,7 +14592,7 @@ "MessageType": { "description": "The type of message", "type": "integer", - "enum": [0, 1, 2, 3, 4, 5, 6, 7, 12, 19], + "enum": [0, 1, 2, 3, 4, 5, 6, 7, 12, 18, 19, 21], "format": "int32", "x-enumNames": [ "DEFAULT", @@ -14222,7 +14604,9 @@ "CHANNEL_PINNED_MESSAGE", "USER_JOIN", "CHANNEL_FOLLOW_ADD", - "REPLY" + "THREAD_CREATED", + "REPLY", + "THREAD_STARTER_MESSAGE" ], "x-enumDescriptions": [ "A regular message", @@ -14234,7 +14618,9 @@ "A system message indicating a message was pinned", "A system message indicating a user joined", "System message posted when a channel starts following an announcement channel", - "A reply message" + "A system message indicating a thread was created", + "A reply message", + "The first message of a thread, pointing at its source" ] }, "MentionReplyPreferences": { @@ -14467,6 +14853,262 @@ {"name": "IS_ANIMATED", "value": "32", "description": "Attachment is animated"} ] }, + "DefaultReactionEmojiResponse": { + "type": "object", + "properties": { + "emoji_id": { + "nullable": true, + "description": "The ID of a custom guild emoji", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "emoji_name": {"nullable": true, "description": "The unicode character of the emoji", "type": "string"} + }, + "required": ["emoji_id", "emoji_name"], + "additionalProperties": false + }, + "ForumTagResponse": { + "type": "object", + "properties": { + "id": {"description": "The ID of the tag", "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]}, + "name": {"type": "string", "maxLength": 50, "description": "The name of the tag"}, + "moderated": {"type": "boolean", "description": "Whether only moderators can add or remove this tag"}, + "emoji_id": { + "nullable": true, + "description": "The ID of a custom guild emoji", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "emoji_name": {"nullable": true, "description": "The unicode character of the emoji", "type": "string"} + }, + "required": ["id", "name", "moderated", "emoji_id", "emoji_name"], + "additionalProperties": false + }, + "ThreadMemberResponse": { + "type": "object", + "properties": { + "id": { + "description": "The ID of the thread", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "user_id": { + "description": "The ID of the user", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "join_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When the user last joined the thread" + }, + "flags": {"description": "Thread member flags", "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, + "muted": {"description": "Whether the user has muted the thread (current user only)", "type": "boolean"}, + "mute_config": { + "description": "The mute configuration for the thread (current user only)", + "nullable": true, + "type": "object", + "properties": { + "end_time": { + "nullable": true, + "description": "ISO8601 timestamp of when the mute expires", + "type": "string" + }, + "selected_time_window": { + "description": "The selected mute duration in seconds", + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + } + }, + "required": ["end_time", "selected_time_window"], + "additionalProperties": false + }, + "member": { + "description": "The guild member object for the user", + "allOf": [{"$ref": "#/components/schemas/GuildMemberResponse"}] + } + }, + "required": ["join_timestamp", "flags"], + "additionalProperties": false + }, + "ThreadMetadataResponse": { + "type": "object", + "properties": { + "archived": {"type": "boolean", "description": "Whether the thread is archived"}, + "auto_archive_duration": { + "description": "Minutes of inactivity before the thread stops showing in the channel list", + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "archive_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When the archive status of the thread last changed" + }, + "locked": {"type": "boolean", "description": "Whether only moderators can interact with the thread"}, + "invitable": { + "description": "Whether non-moderators can add other non-moderators (private threads)", + "type": "boolean" + }, + "create_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When the thread was created" + } + }, + "required": ["archived", "auto_archive_duration", "archive_timestamp", "locked", "create_timestamp"], + "additionalProperties": false + }, + "ChannelOverwriteResponse": { + "type": "object", + "properties": { + "id": { + "description": "The unique identifier for the role or user this overwrite applies to", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "type": {"allOf": [{"$ref": "#/components/schemas/ChannelOverwriteType"}]}, + "allow": { + "description": "The bitwise value of allowed permissions", + "allOf": [{"$ref": "#/components/schemas/Permissions"}] + }, + "deny": { + "description": "The bitwise value of denied permissions", + "allOf": [{"$ref": "#/components/schemas/Permissions"}] + } + }, + "required": ["id", "type", "allow", "deny"], + "additionalProperties": false + }, + "Permissions": { + "type": "string", + "pattern": "^\\d+$", + "description": "Permission bitfield as string", + "format": "int64", + "x-bitflagValues": [ + {"name": "CREATE_INSTANT_INVITE", "value": "1", "description": "Allows creation of instant invites"}, + {"name": "KICK_MEMBERS", "value": "2", "description": "Allows kicking members from the guild"}, + {"name": "BAN_MEMBERS", "value": "4", "description": "Allows banning members from the guild"}, + { + "name": "ADMINISTRATOR", + "value": "8", + "description": "Grants all permissions and bypasses channel permission overwrites" + }, + { + "name": "MANAGE_CHANNELS", + "value": "16", + "description": "Allows management, editing, and deletion of channels" + }, + {"name": "MANAGE_GUILD", "value": "32", "description": "Allows management and editing of the guild"}, + {"name": "ADD_REACTIONS", "value": "64", "description": "Allows adding reactions to messages"}, + {"name": "VIEW_AUDIT_LOG", "value": "128", "description": "Allows viewing of the audit log"}, + { + "name": "PRIORITY_SPEAKER", + "value": "256", + "description": "Allows using priority speaker in a voice channel" + }, + {"name": "STREAM", "value": "512", "description": "Allows the user to go live"}, + {"name": "VIEW_CHANNEL", "value": "1024", "description": "Allows viewing a channel"}, + {"name": "SEND_MESSAGES", "value": "2048", "description": "Allows sending messages in a channel"}, + {"name": "SEND_TTS_MESSAGES", "value": "4096", "description": "Allows sending text-to-speech messages"}, + {"name": "MANAGE_MESSAGES", "value": "8192", "description": "Allows for deleting and pinning messages"}, + {"name": "EMBED_LINKS", "value": "16384", "description": "Links sent will have an embed automatically"}, + {"name": "ATTACH_FILES", "value": "32768", "description": "Allows uploading files"}, + {"name": "READ_MESSAGE_HISTORY", "value": "65536", "description": "Allows reading message history"}, + {"name": "MENTION_EVERYONE", "value": "131072", "description": "Allows using @everyone and @here mentions"}, + {"name": "USE_EXTERNAL_EMOJIS", "value": "262144", "description": "Allows using emojis from other guilds"}, + {"name": "CONNECT", "value": "1048576", "description": "Allows connecting to a voice channel"}, + {"name": "SPEAK", "value": "2097152", "description": "Allows speaking in a voice channel"}, + {"name": "MUTE_MEMBERS", "value": "4194304", "description": "Allows muting members in voice channels"}, + {"name": "DEAFEN_MEMBERS", "value": "8388608", "description": "Allows deafening members in voice channels"}, + {"name": "MOVE_MEMBERS", "value": "16777216", "description": "Allows moving members between voice channels"}, + {"name": "USE_VAD", "value": "33554432", "description": "Allows using voice activity detection"}, + {"name": "CHANGE_NICKNAME", "value": "67108864", "description": "Allows changing own nickname"}, + {"name": "MANAGE_NICKNAMES", "value": "134217728", "description": "Allows changing other members nicknames"}, + {"name": "MANAGE_ROLES", "value": "268435456", "description": "Allows management and editing of roles"}, + {"name": "MANAGE_WEBHOOKS", "value": "536870912", "description": "Allows management and editing of webhooks"}, + { + "name": "MANAGE_EXPRESSIONS", + "value": "1073741824", + "description": "Allows management of guild expressions" + }, + { + "name": "USE_EXTERNAL_STICKERS", + "value": "137438953472", + "description": "Allows using stickers from other guilds" + }, + {"name": "MODERATE_MEMBERS", "value": "1099511627776", "description": "Allows timing out users"}, + {"name": "CREATE_EXPRESSIONS", "value": "8796093022208", "description": "Allows creating guild expressions"}, + {"name": "PIN_MESSAGES", "value": "2251799813685248", "description": "Allows pinning messages"}, + {"name": "BYPASS_SLOWMODE", "value": "4503599627370496", "description": "Allows bypassing slowmode"}, + {"name": "UPDATE_RTC_REGION", "value": "9007199254740992", "description": "Allows updating the voice region"}, + { + "name": "VIEW_CHANNEL_MEMBERS", + "value": "18014398509481984", + "description": "Allows viewing the member list in a channel" + } + ] + }, + "ChannelOverwriteType": { + "description": "The type of entity the overwrite applies to", + "x-enumNames": ["ROLE", "MEMBER"], + "x-enumDescriptions": ["Overwrite applies to a role", "Overwrite applies to a member"], + "enum": [0, 1], + "type": "integer" + }, + "GuildMemberResponse": { + "type": "object", + "properties": { + "user": { + "description": "The user this guild member represents", + "allOf": [{"$ref": "#/components/schemas/UserPartialResponse"}] + }, + "nick": {"description": "The nickname of the member in this guild", "nullable": true, "type": "string"}, + "avatar": {"description": "The hash of the member guild-specific avatar", "nullable": true, "type": "string"}, + "banner": {"description": "The hash of the member guild-specific banner", "nullable": true, "type": "string"}, + "accent_color": { + "description": "The accent colour of the member guild profile as an integer", + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + }, + "roles": { + "maxItems": 250, + "type": "array", + "items": {"type": "string"}, + "description": "Array of role IDs the member has" + }, + "joined_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "ISO8601 timestamp of when the user joined the guild" + }, + "mute": {"type": "boolean", "description": "Whether the member is muted in voice channels"}, + "deaf": {"type": "boolean", "description": "Whether the member is deafened in voice channels"}, + "communication_disabled_until": { + "description": "ISO8601 timestamp until which the member is timed out", + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "profile_flags": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/GuildMemberProfileFlags"}]}, + "mention_flags": { + "nullable": true, + "description": "Per-guild reply mention preference override; NO_PREFERENCE means inherit the user-level mention_flags.", + "allOf": [{"$ref": "#/components/schemas/MentionReplyPreferences"}] + } + }, + "required": ["user", "roles", "joined_at", "mute", "deaf"], + "additionalProperties": false + }, + "GuildMemberProfileFlags": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "description": "Member profile flags", + "format": "int32", + "x-bitflagValues": [ + {"name": "AVATAR_UNSET", "value": "1", "description": "Guild member avatar is unset"}, + {"name": "BANNER_UNSET", "value": "2", "description": "Guild member banner is unset"} + ] + }, "DiscoveryAdminPendingApplicationResponse": { "type": "object", "properties": { @@ -14658,75 +15300,6 @@ ], "additionalProperties": false }, - "Permissions": { - "type": "string", - "pattern": "^\\d+$", - "description": "Permission bitfield as string", - "format": "int64", - "x-bitflagValues": [ - {"name": "CREATE_INSTANT_INVITE", "value": "1", "description": "Allows creation of instant invites"}, - {"name": "KICK_MEMBERS", "value": "2", "description": "Allows kicking members from the guild"}, - {"name": "BAN_MEMBERS", "value": "4", "description": "Allows banning members from the guild"}, - { - "name": "ADMINISTRATOR", - "value": "8", - "description": "Grants all permissions and bypasses channel permission overwrites" - }, - { - "name": "MANAGE_CHANNELS", - "value": "16", - "description": "Allows management, editing, and deletion of channels" - }, - {"name": "MANAGE_GUILD", "value": "32", "description": "Allows management and editing of the guild"}, - {"name": "ADD_REACTIONS", "value": "64", "description": "Allows adding reactions to messages"}, - {"name": "VIEW_AUDIT_LOG", "value": "128", "description": "Allows viewing of the audit log"}, - { - "name": "PRIORITY_SPEAKER", - "value": "256", - "description": "Allows using priority speaker in a voice channel" - }, - {"name": "STREAM", "value": "512", "description": "Allows the user to go live"}, - {"name": "VIEW_CHANNEL", "value": "1024", "description": "Allows viewing a channel"}, - {"name": "SEND_MESSAGES", "value": "2048", "description": "Allows sending messages in a channel"}, - {"name": "SEND_TTS_MESSAGES", "value": "4096", "description": "Allows sending text-to-speech messages"}, - {"name": "MANAGE_MESSAGES", "value": "8192", "description": "Allows for deleting and pinning messages"}, - {"name": "EMBED_LINKS", "value": "16384", "description": "Links sent will have an embed automatically"}, - {"name": "ATTACH_FILES", "value": "32768", "description": "Allows uploading files"}, - {"name": "READ_MESSAGE_HISTORY", "value": "65536", "description": "Allows reading message history"}, - {"name": "MENTION_EVERYONE", "value": "131072", "description": "Allows using @everyone and @here mentions"}, - {"name": "USE_EXTERNAL_EMOJIS", "value": "262144", "description": "Allows using emojis from other guilds"}, - {"name": "CONNECT", "value": "1048576", "description": "Allows connecting to a voice channel"}, - {"name": "SPEAK", "value": "2097152", "description": "Allows speaking in a voice channel"}, - {"name": "MUTE_MEMBERS", "value": "4194304", "description": "Allows muting members in voice channels"}, - {"name": "DEAFEN_MEMBERS", "value": "8388608", "description": "Allows deafening members in voice channels"}, - {"name": "MOVE_MEMBERS", "value": "16777216", "description": "Allows moving members between voice channels"}, - {"name": "USE_VAD", "value": "33554432", "description": "Allows using voice activity detection"}, - {"name": "CHANGE_NICKNAME", "value": "67108864", "description": "Allows changing own nickname"}, - {"name": "MANAGE_NICKNAMES", "value": "134217728", "description": "Allows changing other members nicknames"}, - {"name": "MANAGE_ROLES", "value": "268435456", "description": "Allows management and editing of roles"}, - {"name": "MANAGE_WEBHOOKS", "value": "536870912", "description": "Allows management and editing of webhooks"}, - { - "name": "MANAGE_EXPRESSIONS", - "value": "1073741824", - "description": "Allows management of guild expressions" - }, - { - "name": "USE_EXTERNAL_STICKERS", - "value": "137438953472", - "description": "Allows using stickers from other guilds" - }, - {"name": "MODERATE_MEMBERS", "value": "1099511627776", "description": "Allows timing out users"}, - {"name": "CREATE_EXPRESSIONS", "value": "8796093022208", "description": "Allows creating guild expressions"}, - {"name": "PIN_MESSAGES", "value": "2251799813685248", "description": "Allows pinning messages"}, - {"name": "BYPASS_SLOWMODE", "value": "4503599627370496", "description": "Allows bypassing slowmode"}, - {"name": "UPDATE_RTC_REGION", "value": "9007199254740992", "description": "Allows updating the voice region"}, - { - "name": "VIEW_CHANNEL_MEMBERS", - "value": "18014398509481984", - "description": "Allows viewing the member list in a channel" - } - ] - }, "SystemChannelFlags": { "type": "integer", "minimum": 0, @@ -15047,7 +15620,7 @@ "type": "integer", "enum": [ 1, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 40, 41, 42, 50, 51, 52, 60, 61, 62, - 90, 91, 92, 72, 73, 74, 75 + 90, 91, 92, 72, 73, 74, 75, 110, 111, 112 ], "format": "int32", "x-enumNames": [ @@ -15085,7 +15658,10 @@ "MESSAGE_DELETE", "MESSAGE_BULK_DELETE", "MESSAGE_PIN", - "MESSAGE_UNPIN" + "MESSAGE_UNPIN", + "THREAD_CREATE", + "THREAD_UPDATE", + "THREAD_DELETE" ], "x-enumDescriptions": [ "Guild settings were updated", @@ -15122,7 +15698,10 @@ "Message was deleted", "Messages were bulk deleted", "Message was pinned", - "Message was unpinned" + "Message was unpinned", + "Thread was created", + "Thread was updated", + "Thread was deleted" ] }, "WebhookType": { @@ -15133,63 +15712,6 @@ "x-enumNames": ["INCOMING", "CHANNEL_FOLLOWER"], "x-enumDescriptions": ["Incoming webhook", "Channel follower webhook"] }, - "GuildMemberResponse": { - "type": "object", - "properties": { - "user": { - "description": "The user this guild member represents", - "allOf": [{"$ref": "#/components/schemas/UserPartialResponse"}] - }, - "nick": {"description": "The nickname of the member in this guild", "nullable": true, "type": "string"}, - "avatar": {"description": "The hash of the member guild-specific avatar", "nullable": true, "type": "string"}, - "banner": {"description": "The hash of the member guild-specific banner", "nullable": true, "type": "string"}, - "accent_color": { - "description": "The accent colour of the member guild profile as an integer", - "nullable": true, - "allOf": [{"$ref": "#/components/schemas/Int32Type"}] - }, - "roles": { - "maxItems": 250, - "type": "array", - "items": {"type": "string"}, - "description": "Array of role IDs the member has" - }, - "joined_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", - "description": "ISO8601 timestamp of when the user joined the guild" - }, - "mute": {"type": "boolean", "description": "Whether the member is muted in voice channels"}, - "deaf": {"type": "boolean", "description": "Whether the member is deafened in voice channels"}, - "communication_disabled_until": { - "description": "ISO8601 timestamp until which the member is timed out", - "nullable": true, - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" - }, - "profile_flags": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/GuildMemberProfileFlags"}]}, - "mention_flags": { - "nullable": true, - "description": "Per-guild reply mention preference override; NO_PREFERENCE means inherit the user-level mention_flags.", - "allOf": [{"$ref": "#/components/schemas/MentionReplyPreferences"}] - } - }, - "required": ["user", "roles", "joined_at", "mute", "deaf"], - "additionalProperties": false - }, - "GuildMemberProfileFlags": { - "type": "integer", - "minimum": 0, - "maximum": 2147483647, - "description": "Member profile flags", - "format": "int32", - "x-bitflagValues": [ - {"name": "AVATAR_UNSET", "value": "1", "description": "Guild member avatar is unset"}, - {"name": "BANNER_UNSET", "value": "2", "description": "Guild member banner is unset"} - ] - }, "InstanceBillingUpdateRequest": { "type": "object", "properties": { @@ -15230,6 +15752,36 @@ "poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50} } }, + "ChannelThreadsConfigUpdateRequest": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "guild_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000}, + "guild_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"}, + "enabled_guild_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "disabled_guild_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "user_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000}, + "user_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"}, + "included_user_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "excluded_user_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + } + } + }, "CaptchaConfigUpdateRequest": { "type": "object", "properties": { @@ -15408,6 +15960,68 @@ "required": ["poll_interval_seconds", "poll_jitter_percent"], "additionalProperties": false }, + "ChannelThreadsConfigResponse": { + "type": "object", + "properties": { + "enabled": {"default": false, "type": "boolean"}, + "config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "ever_enabled": {"default": false, "type": "boolean"}, + "guild_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000}, + "guild_salt": { + "default": "channel-threads-guild-v1", + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[\\x20-\\x7e]+$" + }, + "enabled_guild_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "disabled_guild_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "user_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000}, + "user_salt": { + "default": "channel-threads-user-v1", + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[\\x20-\\x7e]+$" + }, + "included_user_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "excluded_user_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + } + }, + "required": [ + "enabled", + "config_version", + "ever_enabled", + "guild_basis_points", + "guild_salt", + "enabled_guild_ids", + "disabled_guild_ids", + "user_basis_points", + "user_salt", + "included_user_ids", + "excluded_user_ids" + ], + "additionalProperties": false + }, "CaptchaConfigResponse": { "type": "object", "properties": { diff --git a/fluxer_admin/src/api/guild_threads.rs b/fluxer_admin/src/api/guild_threads.rs new file mode 100644 index 000000000..35b1e8c26 --- /dev/null +++ b/fluxer_admin/src/api/guild_threads.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::api::generated::snowflake; + +use super::client::{AdminApiClient, ApiResult}; +use super::types::ListGuildThreadsResponse; + +impl AdminApiClient { + pub async fn list_guild_threads(&self, guild_id: &str) -> ApiResult { + let response = self + .generated() + .list_admin_guild_threads(&snowflake(guild_id)) + .await + .map_err(|e| self.generated_error(e))?; + self.generated_value(response.into_inner()) + } + + pub async fn delete_thread_channel(&self, channel_id: &str) -> ApiResult<()> { + self.generated() + .delete_admin_thread_channel(&snowflake(channel_id)) + .await + .map_err(|e| self.generated_error(e))?; + Ok(()) + } +} diff --git a/fluxer_admin/src/api/mod.rs b/fluxer_admin/src/api/mod.rs index 5ab8efebb..9f007718d 100644 --- a/fluxer_admin/src/api/mod.rs +++ b/fluxer_admin/src/api/mod.rs @@ -13,6 +13,7 @@ pub mod client; pub mod codes; pub mod discovery; pub mod guild_assets; +pub mod guild_threads; pub mod guilds; pub mod instance_config; pub mod jobs; diff --git a/fluxer_admin/src/api/types/guild_threads.rs b/fluxer_admin/src/api/types/guild_threads.rs new file mode 100644 index 000000000..ff270b370 --- /dev/null +++ b/fluxer_admin/src/api/types/guild_threads.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct GuildThreadMetadata { + pub archived: bool, + pub locked: bool, + pub auto_archive_duration: i32, + pub archive_timestamp: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct GuildThreadItem { + pub id: String, + #[serde(rename = "type")] + pub channel_type: i32, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub parent_id: Option, + #[serde(default)] + pub owner_id: Option, + #[serde(default)] + pub member_count: Option, + #[serde(default)] + pub message_count: Option, + #[serde(default)] + pub thread_metadata: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ListGuildThreadsResponse { + pub threads: Vec, +} diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index a34a1cfe7..005ffdf39 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -31,6 +31,8 @@ pub struct InstanceConfigResponse { #[serde(default)] pub captcha: CaptchaConfigResponse, #[serde(default)] + pub channel_threads: ChannelThreadsConfigResponse, + #[serde(default)] pub experiment_delivery: ExperimentDeliveryConfigResponse, #[serde(default)] pub billing: InstanceBillingResponse, @@ -510,6 +512,8 @@ pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1"; pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1"; pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive = 1_000..=20_000; pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive = 100..=20_000; +pub const CHANNEL_THREADS_DEFAULT_GUILD_SALT: &str = "channel-threads-guild-v1"; +pub const CHANNEL_THREADS_DEFAULT_USER_SALT: &str = "channel-threads-user-v1"; #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(default)] @@ -653,6 +657,62 @@ pub struct CaptchaConfigUpdateRequest { pub max_counter: Option, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct ChannelThreadsConfigResponse { + pub enabled: bool, + pub config_version: u64, + pub ever_enabled: bool, + pub guild_basis_points: u32, + pub guild_salt: String, + pub enabled_guild_ids: Vec, + pub disabled_guild_ids: Vec, + pub user_basis_points: u32, + pub user_salt: String, + pub included_user_ids: Vec, + pub excluded_user_ids: Vec, +} + +impl Default for ChannelThreadsConfigResponse { + fn default() -> Self { + Self { + enabled: false, + config_version: 0, + ever_enabled: false, + guild_basis_points: 0, + guild_salt: CHANNEL_THREADS_DEFAULT_GUILD_SALT.to_owned(), + enabled_guild_ids: Vec::new(), + disabled_guild_ids: Vec::new(), + user_basis_points: 0, + user_salt: CHANNEL_THREADS_DEFAULT_USER_SALT.to_owned(), + included_user_ids: Vec::new(), + excluded_user_ids: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct ChannelThreadsConfigUpdateRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub guild_basis_points: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub guild_salt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_guild_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_guild_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_basis_points: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_salt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub included_user_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_user_ids: Option>, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] pub struct ExperimentDeliveryConfigResponse { @@ -775,6 +835,8 @@ pub struct InstanceConfigUpdateRequest { #[serde(skip_serializing_if = "Option::is_none")] pub captcha: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub channel_threads: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub experiment_delivery: Option, #[serde(skip_serializing_if = "Option::is_none")] pub billing: Option, diff --git a/fluxer_admin/src/api/types/mod.rs b/fluxer_admin/src/api/types/mod.rs index 507c5b7bb..bf337d152 100644 --- a/fluxer_admin/src/api/types/mod.rs +++ b/fluxer_admin/src/api/types/mod.rs @@ -9,6 +9,7 @@ mod codes; mod common; mod discovery; mod guild_assets; +mod guild_threads; mod instance_billing; mod instance_config; mod jobs; @@ -29,6 +30,7 @@ pub use codes::*; pub use common::*; pub use discovery::*; pub use guild_assets::*; +pub use guild_threads::*; pub use instance_billing::*; pub use instance_config::*; pub use jobs::*; diff --git a/fluxer_admin/src/routes/guild_tabs.rs b/fluxer_admin/src/routes/guild_tabs.rs index f64b29cc7..d8fcdca7a 100644 --- a/fluxer_admin/src/routes/guild_tabs.rs +++ b/fluxer_admin/src/routes/guild_tabs.rs @@ -96,6 +96,33 @@ pub async fn render( config, &guild, &stickers, csrf_token, )) } + "threads" => { + if !acl::has_permission(admin_acls, acl::GUILD_LOOKUP) { + return None; + } + let threads = client + .list_guild_threads(guild_id) + .await + .map(|response| response.threads) + .map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild threads")) + .unwrap_or_default(); + let threads_enabled = client + .get_instance_config() + .await + .map(|instance| instance.channel_threads.enabled) + .map_err( + |error| tracing::warn!(%error, "admin API request failed: get instance config"), + ) + .unwrap_or(false); + Some(tabs::threads::threads_tab( + config, + &guild, + &threads, + acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL), + threads_enabled, + csrf_token, + )) + } "audit_log" | "audit-log" => { if !acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW) { return None; diff --git a/fluxer_admin/src/routes/guilds.rs b/fluxer_admin/src/routes/guilds.rs index ed700b73f..9656058d6 100644 --- a/fluxer_admin/src/routes/guilds.rs +++ b/fluxer_admin/src/routes/guilds.rs @@ -433,6 +433,16 @@ async fn dispatch_guild_action( "Failed to delete sticker", ) } + "delete_thread" => { + let Some(thread_id) = get("thread_id") else { + return FlashData::error("Thread ID is required"); + }; + action_result( + client.delete_thread_channel(&thread_id).await, + "Thread deleted", + "Failed to delete thread", + ) + } "trigger_archive" => { let inc = form.bool_value("include_attachments"); action_result( diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 0723bfa09..1cf8e4ae9 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -7,19 +7,20 @@ use crate::{ AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, - CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest, - DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS, - ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest, - GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest, - InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest, - InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest, - InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest, - InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest, - InstanceMediaUpdateRequest, InstancePolicyUpdateRequest, - InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest, - InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule, - LimitRuleFilters, PlutoniumPageConfigUpdateRequest, PremiumMode, - PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope, + CaptchaConfigUpdateRequest, ChannelThreadsConfigUpdateRequest, + CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest, + EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest, + GatewayRolloutConfigUpdateRequest, GatewayRolloutMode, + InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest, + InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest, + InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest, + InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest, + InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest, + InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest, + InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest, + LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, + PlutoniumPageConfigUpdateRequest, PremiumMode, PushRelayConfigUpdateRequest, + RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope, }, }, config::AdminConfig, @@ -228,6 +229,10 @@ pub async fn instance_config_post( Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), }, + "update_channel_threads" => match build_channel_threads_update(&form) { + Ok(update) => instance_config_result(client.update_instance_config(&update).await), + Err(message) => FlashData::error(message), + }, "update_experiment_delivery" => match build_experiment_delivery_update(&form) { Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), @@ -671,6 +676,53 @@ fn build_captcha_update(form: &MultiValueForm) -> Result Result { + Ok(InstanceConfigUpdateRequest { + channel_threads: Some(ChannelThreadsConfigUpdateRequest { + enabled: Some(form.bool_value("channel_threads_enabled")), + guild_basis_points: parse_form_number( + form, + "channel_threads_guild_basis_points", + "Guild rollout basis points", + 0, + EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, + )?, + guild_salt: parse_experiment_rollout_salt(form, "channel_threads_guild_salt")?, + enabled_guild_ids: Some(parse_experiment_user_ids( + form.first("channel_threads_enabled_guild_ids") + .unwrap_or_default(), + "Enabled guild IDs", + )?), + disabled_guild_ids: Some(parse_experiment_user_ids( + form.first("channel_threads_disabled_guild_ids") + .unwrap_or_default(), + "Disabled guild IDs", + )?), + user_basis_points: parse_form_number( + form, + "channel_threads_user_basis_points", + "User rollout basis points", + 0, + EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, + )?, + user_salt: parse_experiment_rollout_salt(form, "channel_threads_user_salt")?, + included_user_ids: Some(parse_experiment_user_ids( + form.first("channel_threads_included_user_ids") + .unwrap_or_default(), + "Included user IDs", + )?), + excluded_user_ids: Some(parse_experiment_user_ids( + form.first("channel_threads_excluded_user_ids") + .unwrap_or_default(), + "Excluded user IDs", + )?), + }), + ..Default::default() + }) +} + fn build_experiment_delivery_update( form: &MultiValueForm, ) -> Result { @@ -1436,6 +1488,111 @@ mod tests { } } + #[test] + fn build_channel_threads_update_reads_both_rollout_dimensions() { + let form = MultiValueForm::parse( + b"channel_threads_enabled=true&channel_threads_guild_basis_points=%2010%20&channel_threads_guild_salt=%20channel-threads-guild-v2%20&channel_threads_enabled_guild_ids=1600000000000000001%0A1600000000000000002%0A1600000000000000001&channel_threads_disabled_guild_ids=1600000000000000003&channel_threads_user_basis_points=10000&channel_threads_user_salt=channel-threads-user-v2&channel_threads_included_user_ids=1500000000000000001&channel_threads_excluded_user_ids=1500000000000000003%2C%201500000000000000004", + ); + let update = build_channel_threads_update(&form) + .expect("valid form") + .channel_threads + .expect("channel threads update"); + assert_eq!(update.enabled, Some(true)); + assert_eq!(update.guild_basis_points, Some(10)); + assert_eq!( + update.guild_salt, + Some("channel-threads-guild-v2".to_owned()) + ); + assert_eq!( + update.enabled_guild_ids, + Some(vec![ + "1600000000000000001".to_owned(), + "1600000000000000002".to_owned() + ]) + ); + assert_eq!( + update.disabled_guild_ids, + Some(vec!["1600000000000000003".to_owned()]) + ); + assert_eq!(update.user_basis_points, Some(10000)); + assert_eq!(update.user_salt, Some("channel-threads-user-v2".to_owned())); + assert_eq!( + update.included_user_ids, + Some(vec!["1500000000000000001".to_owned()]) + ); + assert_eq!( + update.excluded_user_ids, + Some(vec![ + "1500000000000000003".to_owned(), + "1500000000000000004".to_owned() + ]) + ); + } + + #[test] + fn build_channel_threads_update_leaves_the_experiment_off_when_nothing_is_submitted() { + let form = MultiValueForm::parse(b"_csrf=token"); + let request = build_channel_threads_update(&form).expect("valid form"); + assert_eq!( + serde_json::to_value(request).expect("serializable update"), + serde_json::json!({"channel_threads": { + "enabled": false, + "enabled_guild_ids": [], + "disabled_guild_ids": [], + "included_user_ids": [], + "excluded_user_ids": [], + }}) + ); + } + + #[test] + fn build_channel_threads_update_rejects_invalid_targeting() { + let too_many_guilds = (0..=EXPERIMENT_MAX_TARGETED_USERS) + .map(|index| index.to_string()) + .collect::>() + .join("%2C"); + for (form, message) in [ + ( + "channel_threads_guild_basis_points=10001".to_owned(), + "Guild rollout basis points must be a whole number between 0 and 10000", + ), + ( + "channel_threads_user_basis_points=-1".to_owned(), + "User rollout basis points must be a whole number between 0 and 10000", + ), + ( + "channel_threads_guild_salt=%20%20".to_owned(), + "Rollout salt must be between 1 and 64 characters", + ), + ( + "channel_threads_user_salt=caf%C3%A9".to_owned(), + "Rollout salt must use printable ASCII", + ), + ( + "channel_threads_enabled_guild_ids=123%2Cinvalid".to_owned(), + "Enabled guild IDs entry 2 must contain 1 to 20 decimal digits", + ), + ( + "channel_threads_disabled_guild_ids=123456789012345678901".to_owned(), + "Disabled guild IDs entry 1 must contain 1 to 20 decimal digits", + ), + ( + "channel_threads_excluded_user_ids=abc".to_owned(), + "Excluded user IDs entry 1 must contain 1 to 20 decimal digits", + ), + ( + format!("channel_threads_enabled_guild_ids={too_many_guilds}"), + "Enabled guild IDs must contain at most 1000 unique IDs", + ), + ] { + let form = MultiValueForm::parse(form.as_bytes()); + assert_eq!( + build_channel_threads_update(&form).expect_err("invalid targeting"), + message + ); + } + } + #[test] fn build_push_relay_update_reads_the_consent_checkbox() { let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token")); diff --git a/fluxer_admin/src/templates/pages/guild_detail.rs b/fluxer_admin/src/templates/pages/guild_detail.rs index 7add2f059..f513e640a 100644 --- a/fluxer_admin/src/templates/pages/guild_detail.rs +++ b/fluxer_admin/src/templates/pages/guild_detail.rs @@ -26,6 +26,7 @@ pub const GUILD_TABS: &[(&str, &str)] = &[ ("archives", "Archives"), ("emojis", "Emojis"), ("stickers", "Stickers"), + ("threads", "Threads"), ("audit_logs", "Admin Audit Logs"), ("audit_log", "Guild Audit Log"), ("reports", "Reports"), @@ -208,6 +209,7 @@ fn guild_tab_visible(_config: &AdminConfig, tab_id: &str, admin_acls: &[String]) "overview" | "members" | "settings" | "features" | "moderation" => true, "reports" => acl::has_permission(admin_acls, acl::REPORT_VIEW), "emojis" | "stickers" => acl::has_permission(admin_acls, acl::ASSET_PURGE), + "threads" => acl::has_permission(admin_acls, acl::GUILD_LOOKUP), "audit_logs" => acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW), "audit_log" => acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW), "archives" => acl::has_any_permission( diff --git a/fluxer_admin/src/templates/pages/guild_detail_tabs/mod.rs b/fluxer_admin/src/templates/pages/guild_detail_tabs/mod.rs index 5c58b4e7c..afc3a04ff 100644 --- a/fluxer_admin/src/templates/pages/guild_detail_tabs/mod.rs +++ b/fluxer_admin/src/templates/pages/guild_detail_tabs/mod.rs @@ -11,6 +11,7 @@ pub mod overview; pub mod reports; pub mod settings; pub mod stickers; +pub mod threads; use crate::{api::types::GuildDetailInfo, utils::user_tag::user_tag}; diff --git a/fluxer_admin/src/templates/pages/guild_detail_tabs/overview.rs b/fluxer_admin/src/templates/pages/guild_detail_tabs/overview.rs index 225252543..5735a51d0 100644 --- a/fluxer_admin/src/templates/pages/guild_detail_tabs/overview.rs +++ b/fluxer_admin/src/templates/pages/guild_detail_tabs/overview.rs @@ -33,7 +33,11 @@ fn channel_type_label(channel_type: i32) -> &'static str { 2 => "Voice", 4 => "Category", 5 => "Announcement", - 13 => "Link", + 11 => "Public thread", + 12 => "Private thread", + 15 => "Forum", + 16 => "Media", + 998 => "Link", _ => "Unknown", } } @@ -179,7 +183,7 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: & } @else { div class="flex flex-col gap-2" { @for channel in &sorted_channels { - @let is_link = channel.channel_type == 13; + @let is_link = channel.channel_type == 998; @let parent = channel.parent_id.as_deref() .and_then(|pid| channels_by_id.get(pid)); @let parent_nsfw_override = parent diff --git a/fluxer_admin/src/templates/pages/guild_detail_tabs/threads.rs b/fluxer_admin/src/templates/pages/guild_detail_tabs/threads.rs new file mode 100644 index 000000000..2769c0992 --- /dev/null +++ b/fluxer_admin/src/templates/pages/guild_detail_tabs/threads.rs @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + api::types::{GuildInfo, GuildThreadItem}, + config::AdminConfig, + templates::components::{ + badge::{BadgeVariant, badge}, + form::{csrf_input, danger_button, form_actions, submit_button}, + page_container::card_with_header, + table::{data_table, table_cell, table_row}, + }, +}; +use maud::{Markup, html}; + +pub fn threads_tab( + config: &AdminConfig, + guild: &GuildInfo, + threads: &[GuildThreadItem], + can_delete: bool, + can_reindex: bool, + csrf_token: &str, +) -> Markup { + let base = &config.base_path; + html! { + @if can_reindex { + (card_with_header("Thread search index", html! { + form method="post" + action={(base) "/guilds/" (guild.id) "?tab=threads&action=refresh_search_index"} + class="w-full" { + (csrf_input(csrf_token)) + input type="hidden" name="index_type" value="threads"; + (form_actions(html! { + (submit_button("Refresh threads")) + })) + } + })) + } + (card_with_header( + &format!("Threads ({})", threads.len()), + html! { + @if threads.is_empty() { + p class="text-sm text-neutral-500" { "No threads found for this guild." } + } @else { + (data_table( + &["Thread", "Parent", "State", "Members", "Messages", ""], + html! { + @for thread in threads { + (thread_row(base, &guild.id, thread, can_delete, csrf_token)) + } + }, + )) + } + }, + )) + } +} + +fn thread_kind(channel_type: i32) -> &'static str { + match channel_type { + 10 => "Announcement", + 12 => "Private", + _ => "Public", + } +} + +fn thread_state(thread: &GuildThreadItem) -> Markup { + let metadata = thread.thread_metadata.as_ref(); + let archived = metadata.is_some_and(|metadata| metadata.archived); + let locked = metadata.is_some_and(|metadata| metadata.locked); + html! { + div class="flex flex-wrap gap-1" { + (badge(thread_kind(thread.channel_type), BadgeVariant::Default)) + @if archived { (badge("Archived", BadgeVariant::Default)) } + @if locked { (badge("Locked", BadgeVariant::Default)) } + } + } +} + +fn thread_row( + base: &str, + guild_id: &str, + thread: &GuildThreadItem, + can_delete: bool, + csrf_token: &str, +) -> Markup { + table_row(html! { + (table_cell(false, html! { + div class="font-medium" { (thread.name.as_deref().unwrap_or("")) } + div class="text-xs text-neutral-500" { "ID: " (thread.id) } + })) + (table_cell(true, html! { (thread.parent_id.as_deref().unwrap_or("")) })) + (table_cell(false, thread_state(thread))) + (table_cell(true, html! { (thread.member_count.unwrap_or(0)) })) + (table_cell(true, html! { (thread.message_count.unwrap_or(0)) })) + (table_cell(false, html! { + @if can_delete { + form method="post" + action={(base) "/guilds/" (guild_id) "?tab=threads&action=delete_thread"} { + (csrf_input(csrf_token)) + input type="hidden" name="thread_id" value=(thread.id); + (danger_button("Delete thread")) + } + } + })) + }) +} diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index bfe01465e..75d7a87c3 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -3,7 +3,8 @@ use crate::{ api::types::{ AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse, - CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse, + CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CHANNEL_THREADS_DEFAULT_GUILD_SALT, + CHANNEL_THREADS_DEFAULT_USER_SALT, CaptchaConfigResponse, ChannelThreadsConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse, @@ -16,6 +17,7 @@ use crate::{ middleware::auth::AuthContext, templates::{ components::{ + alert::alert_warning, badge::{BadgeVariant, badge}, form::{ FORM_INPUT_CLASS, checkbox, csrf_input, danger_button, form_actions, @@ -191,6 +193,7 @@ pub fn instance_config_page( html! { (gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout)) (domain_migration_section(base, csrf_token, &instance_config.domain_migration)) + (channel_threads_section(base, csrf_token, &instance_config.channel_threads)) (plutonium_page_section(base, csrf_token, &instance_config.plutonium_page)) (experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery)) @if let Some(limit_config) = limit_config { @@ -1484,6 +1487,189 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse ) } +fn channel_threads_section( + base: &str, + csrf_token: &str, + channel_threads: &ChannelThreadsConfigResponse, +) -> Markup { + let status = if channel_threads.enabled { + ("Live", BadgeVariant::Success) + } else { + ("Inert", BadgeVariant::Default) + }; + let enabled_guild_ids = channel_threads.enabled_guild_ids.join("\n"); + let disabled_guild_ids = channel_threads.disabled_guild_ids.join("\n"); + let included_user_ids = channel_threads.included_user_ids.join("\n"); + let excluded_user_ids = channel_threads.excluded_user_ids.join("\n"); + section_card_with_description( + "Channel threads", + "Threads, forum channels and media channels. A guild gets the feature only when the guild \ + is selected, and a member sees it only when they are also selected and use a client \ + that supports threads. Bots follow the guild selection.", + html! { + form method="post" action={(base) "/instance-config?action=update_channel_threads"} { + (csrf_input(csrf_token)) + div class="space-y-6" { + (alert_warning("Before enabling", html! { + p class="text-sm" { + "Enable only after every gateway role, the api, the workers and the \ + messages service run the gate build and the bit 34-38 overwrite audit \ + is clean." + } + })) + div class="flex flex-wrap items-center gap-2" { + h3 class="text-sm font-semibold text-neutral-900" { "Master switch" } + (badge(status.0, status.1)) + @if channel_threads.ever_enabled { + (badge("Ever enabled", BadgeVariant::Warning)) + } + span class="text-xs text-neutral-500" { + "Config version " (channel_threads.config_version) + } + } + (checkbox( + "channel_threads_enabled", + "true", + "Turn on threads for the selected guilds and users", + channel_threads.enabled, + true, + )) + p class="text-xs text-neutral-500" { + "Off is the safe state and the kill switch. With this unchecked no guild \ + has threads, and existing threads and forums stay stored but hidden \ + until it is turned back on." + } + + h3 class="text-sm font-semibold text-neutral-900" { "Guilds" } + (number_field( + "channel_threads_guild_basis_points", + "Guild rollout (basis points)", + &channel_threads.guild_basis_points.to_string(), + Some(0), Some(10000), "1", + Some("Share of guilds bucketed into the experiment, in basis points: 0 is nobody, 100 is 1%, 10000 is every guild."), + )) + div class="flex flex-col gap-2" { + (text_input( + "channel_threads_guild_salt", + "Guild rollout salt", + &channel_threads.guild_salt, + CHANNEL_THREADS_DEFAULT_GUILD_SALT, + )) + p class="text-xs text-neutral-500" { + "Seeds the guild bucketing hash. Printable ASCII only. Changing it \ + reshuffles which guilds fall inside the percentage above." + } + } + div class="flex flex-col gap-2" { + (textarea_input( + "channel_threads_enabled_guild_ids", + "Always-on Guild IDs", + "1600000000000000001\n1600000000000000002", + &enabled_guild_ids, + 4, + false, + )) + (entry_count_hint( + channel_threads.enabled_guild_ids.len(), + EXPERIMENT_MAX_TARGETED_USERS, + )) + p class="text-xs text-neutral-500" { + "One snowflake per line, or comma separated. These guilds are targeted \ + regardless of the percentage above. IDs must contain 1 to 20 decimal \ + digits. Invalid entries prevent the save. Blank entries and duplicate \ + IDs are ignored." + } + } + div class="flex flex-col gap-2" { + (textarea_input( + "channel_threads_disabled_guild_ids", + "Never-on Guild IDs", + "1600000000000000003\n1600000000000000004", + &disabled_guild_ids, + 4, + false, + )) + (entry_count_hint( + channel_threads.disabled_guild_ids.len(), + EXPERIMENT_MAX_TARGETED_USERS, + )) + p class="text-xs text-neutral-500" { + "Same format. Exclusion wins over both the always-on list and the \ + percentage. This is the per-guild kill switch." + } + } + + h3 class="text-sm font-semibold text-neutral-900" { "Users" } + (number_field( + "channel_threads_user_basis_points", + "User rollout (basis points)", + &channel_threads.user_basis_points.to_string(), + Some(0), Some(10000), "1", + Some("Share of users bucketed into the experiment, in basis points. Set 10000 before enrolling any guild outside staff, so every member of that guild, moderators included, sees its threads."), + )) + div class="flex flex-col gap-2" { + (text_input( + "channel_threads_user_salt", + "User rollout salt", + &channel_threads.user_salt, + CHANNEL_THREADS_DEFAULT_USER_SALT, + )) + p class="text-xs text-neutral-500" { + "Seeds the user bucketing hash. Printable ASCII only. Changing it \ + reshuffles which users fall inside the percentage above." + } + } + div class="flex flex-col gap-2" { + (textarea_input( + "channel_threads_included_user_ids", + "Always-on User IDs", + "1500000000000000001\n1500000000000000002", + &included_user_ids, + 4, + false, + )) + (entry_count_hint( + channel_threads.included_user_ids.len(), + EXPERIMENT_MAX_TARGETED_USERS, + )) + p class="text-xs text-neutral-500" { + "Same format as the guild lists. These users are targeted regardless \ + of the percentage above." + } + } + div class="flex flex-col gap-2" { + (textarea_input( + "channel_threads_excluded_user_ids", + "Never-on User IDs", + "1500000000000000003\n1500000000000000004", + &excluded_user_ids, + 4, + false, + )) + (entry_count_hint( + channel_threads.excluded_user_ids.len(), + EXPERIMENT_MAX_TARGETED_USERS, + )) + p class="text-xs text-neutral-500" { + "Same format. Exclusion wins over both the always-on list and the \ + percentage, and it also applies to bots." + } + } + (alert_warning("Excluded bots", html! { + p class="text-sm" { + "Excluded bots are blind to threads, including moderation bots." + } + })) + + (form_actions(html! { + (submit_button("Save channel threads configuration")) + })) + } + } + }, + ) +} + fn experiment_delivery_section( base: &str, csrf_token: &str, @@ -2227,6 +2413,52 @@ mod tests { assert!(!markup.contains("at the cap")); } + #[test] + fn channel_threads_section_shows_both_dimensions_and_the_warnings() { + let channel_threads = ChannelThreadsConfigResponse { + enabled: true, + config_version: 7, + ever_enabled: true, + guild_basis_points: 25, + enabled_guild_ids: vec!["1600000000000000001".to_owned()], + excluded_user_ids: vec![ + "1500000000000000002".to_owned(), + "1500000000000000003".to_owned(), + ], + ..ChannelThreadsConfigResponse::default() + }; + let markup = channel_threads_section("/admin", "csrf", &channel_threads).into_string(); + assert!(markup.contains("action=update_channel_threads")); + for name in [ + "channel_threads_enabled", + "channel_threads_guild_basis_points", + "channel_threads_guild_salt", + "channel_threads_enabled_guild_ids", + "channel_threads_disabled_guild_ids", + "channel_threads_user_basis_points", + "channel_threads_user_salt", + "channel_threads_included_user_ids", + "channel_threads_excluded_user_ids", + ] { + assert!(markup.contains(&format!("name=\"{name}\"")), "{name}"); + } + assert!(markup.contains("value=\"25\"")); + assert!(markup.contains("Config version 7")); + assert!(markup.contains("Ever enabled")); + assert!(markup.contains("bit 34-38 overwrite audit is clean")); + assert!(markup.contains("Excluded bots are blind to threads, including moderation bots.")); + assert!(markup.contains("1 of 1000 stored")); + assert!(markup.contains("2 of 1000 stored")); + + let off = + channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default()) + .into_string(); + assert!(off.contains("Inert")); + assert!(!off.contains("Ever enabled")); + assert!(off.contains(CHANNEL_THREADS_DEFAULT_GUILD_SALT)); + assert!(off.contains(CHANNEL_THREADS_DEFAULT_USER_SALT)); + } + #[test] fn plutonium_page_section_shows_the_rollout_and_list_counts() { let plutonium_page = PlutoniumPageConfigResponse { diff --git a/fluxer_admin/tests/api_deserialization.rs b/fluxer_admin/tests/api_deserialization.rs index 75522bf6e..b525d3b58 100644 --- a/fluxer_admin/tests/api_deserialization.rs +++ b/fluxer_admin/tests/api_deserialization.rs @@ -433,6 +433,19 @@ fn deserialize_instance_config_response_with_unknown_keys() { "max_counter": 1000, "future_captcha_knob": 1 }, + "channel_threads": { + "enabled": true, + "config_version": 3, + "ever_enabled": true, + "guild_basis_points": 0, + "guild_salt": "channel-threads-guild-v1", + "enabled_guild_ids": ["1600000000000000001"], + "disabled_guild_ids": [], + "user_basis_points": 10000, + "user_salt": "channel-threads-user-v1", + "included_user_ids": [], + "excluded_user_ids": [] + }, "experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15}, "registration": { "mode": "open", @@ -595,6 +608,9 @@ fn deserialize_instance_config_response_with_unknown_keys() { assert!(resp.push_relay.relay_consent_accepted); assert!(resp.captcha.enabled); assert_eq!(resp.captcha.max_counter, 1000); + assert!(resp.channel_threads.enabled); + assert_eq!(resp.channel_threads.config_version, 3); + assert_eq!(resp.channel_threads.enabled_guild_ids.len(), 1); assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300); assert!(resp.policy.single_community_guild_id.is_none()); assert_eq!(resp.policy.services.gif_enabled, Some(true)); @@ -662,6 +678,63 @@ fn deserialize_instance_config_response_with_unknown_keys() { ); } +#[test] +fn deserialize_channel_threads_config() { + let config: types::ChannelThreadsConfigResponse = serde_json::from_str( + r#"{ + "enabled": true, + "config_version": 12, + "ever_enabled": true, + "guild_basis_points": 50, + "guild_salt": "channel-threads-guild-v2", + "enabled_guild_ids": ["1600000000000000001"], + "disabled_guild_ids": ["1600000000000000002", "1600000000000000003"], + "user_basis_points": 10000, + "user_salt": "channel-threads-user-v1", + "included_user_ids": [], + "excluded_user_ids": ["1500000000000000001"], + "future_threads_knob": 1 + }"#, + ) + .expect("a channel threads config must deserialize"); + + assert!(config.enabled); + assert!(config.ever_enabled); + assert_eq!(config.config_version, 12); + assert_eq!(config.guild_basis_points, 50); + assert_eq!(config.guild_salt, "channel-threads-guild-v2"); + assert_eq!(config.enabled_guild_ids, vec!["1600000000000000001"]); + assert_eq!(config.disabled_guild_ids.len(), 2); + assert_eq!(config.user_basis_points, 10000); + assert_eq!(config.excluded_user_ids, vec!["1500000000000000001"]); + + let absent: types::ChannelThreadsConfigResponse = + serde_json::from_str("{}").expect("an api without the experiment still deserializes"); + assert!(!absent.enabled); + assert!(!absent.ever_enabled); + assert_eq!(absent.guild_salt, types::CHANNEL_THREADS_DEFAULT_GUILD_SALT); + assert_eq!(absent.user_salt, types::CHANNEL_THREADS_DEFAULT_USER_SALT); +} + +#[test] +fn serialize_channel_threads_update_never_sends_server_owned_fields() { + let update = types::InstanceConfigUpdateRequest { + channel_threads: Some(types::ChannelThreadsConfigUpdateRequest { + enabled: Some(true), + enabled_guild_ids: Some(vec!["1600000000000000001".to_owned()]), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&update).unwrap(), + serde_json::json!({"channel_threads": { + "enabled": true, + "enabled_guild_ids": ["1600000000000000001"], + }}) + ); +} + #[test] fn deserialize_push_relay_config() { let accepted: types::PushRelayConfigResponse = serde_json::from_str( @@ -1084,3 +1157,48 @@ fn account_identity_lock_is_unknown_when_the_api_omits_it() { assert_eq!(identity.mode, types::AccountIdentityMode::Email); assert_eq!(identity.locked, None); } + +#[test] +fn deserialize_guild_threads_response() { + let json = r#"{ + "threads": [ + { + "id": "1600000000000000010", + "type": 12, + "guild_id": "1600000000000000001", + "parent_id": "1600000000000000002", + "owner_id": "1500000000000000001", + "name": "secret plans", + "last_message_id": null, + "last_pin_timestamp": null, + "rate_limit_per_user": 0, + "flags": 0, + "thread_metadata": { + "archived": true, + "auto_archive_duration": 4320, + "archive_timestamp": "2026-09-27T12:00:00.000Z", + "locked": false, + "invitable": false, + "create_timestamp": "2026-09-26T12:00:00.000Z" + }, + "message_count": 3, + "total_message_sent": 4, + "member_count": 2 + } + ] + }"#; + let generated: generated_types::ListGuildThreadsResponse = + serde_json::from_str(json).expect("the generated client must accept the thread list"); + assert_eq!(generated.threads.len(), 1); + let resp: types::ListGuildThreadsResponse = serde_json::from_str(json).unwrap(); + let thread = &resp.threads[0]; + assert_eq!(thread.channel_type, 12); + assert_eq!(thread.name.as_deref(), Some("secret plans")); + assert_eq!(thread.member_count, Some(2)); + assert!( + thread + .thread_metadata + .as_ref() + .is_some_and(|m| m.archived && !m.locked) + ); +} diff --git a/fluxer_admin/tests/htmx_acceptance.rs b/fluxer_admin/tests/htmx_acceptance.rs index b3d681455..b5db73976 100644 --- a/fluxer_admin/tests/htmx_acceptance.rs +++ b/fluxer_admin/tests/htmx_acceptance.rs @@ -469,6 +469,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() { "/instance-config?action=update_gateway_rollout", "/instance-config?action=update_sso", "/instance-config?action=update_domain_migration", + "/instance-config?action=update_channel_threads", "/instance-config?action=update_plutonium_page", "/instance-config?action=update_experiment_delivery", ][..], @@ -485,6 +486,57 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() { } } +#[tokio::test] +async fn channel_threads_section_renders_and_saves_through_htmx_toasts() { + let app = setup().await; + let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await; + assert_full_layout(&body); + assert!(body.contains("Channel threads"), "{body}"); + assert!(body.contains("Config version 3"), "{body}"); + assert!(body.contains("Ever enabled"), "{body}"); + assert!(body.contains("1600000000000000001"), "{body}"); + assert!(body.contains("1500000000000000009"), "{body}"); + assert!( + body.contains("Excluded bots are blind to threads, including moderation bots."), + "{body}" + ); + let csrf_token = csrf_cookie(&headers) + .unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{body}")); + let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token); + let htmx_headers = [ + ("HX-Request", "true"), + ("HX-Target", "flash-container"), + ("Cookie", cookie.as_str()), + ]; + + for (form, expected) in [ + ( + format!( + "_csrf={csrf_token}&channel_threads_enabled=true&channel_threads_guild_basis_points=0&channel_threads_enabled_guild_ids=1600000000000000001&channel_threads_user_basis_points=10000" + ), + "Instance config updated", + ), + ( + format!("_csrf={csrf_token}&channel_threads_enabled_guild_ids=not-a-guild"), + "Enabled guild IDs entry 1 must contain 1 to 20 decimal digits", + ), + ] { + let (status, response_headers, response_body) = post_form_with_headers( + &app, + "/instance-config?action=update_channel_threads", + &htmx_headers, + &form, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "{response_body}"); + let toast = response_headers + .get("X-Fluxer-Admin-Toast") + .and_then(|value| value.to_str().ok()) + .unwrap_or_else(|| panic!("missing toast header\n{response_body}")); + assert!(toast.contains(expected), "{toast}"); + } +} + #[tokio::test] async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() { let app = setup().await; @@ -1193,6 +1245,19 @@ fn instance_config() -> Value { "anonymous_rollout_basis_points": 0, "standalone_forwarding": false }, + "channel_threads": { + "enabled": false, + "config_version": 3, + "ever_enabled": true, + "guild_basis_points": 0, + "guild_salt": "channel-threads-guild-v1", + "enabled_guild_ids": ["1600000000000000001"], + "disabled_guild_ids": [], + "user_basis_points": 10000, + "user_salt": "channel-threads-user-v1", + "included_user_ids": [], + "excluded_user_ids": ["1500000000000000009"] + }, "plutonium_page": { "enabled": false, "config_version": 0, diff --git a/fluxer_api/pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions.ts b/fluxer_api/pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions.ts index 9069b61fd..63d699ad7 100644 --- a/fluxer_api/pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions.ts +++ b/fluxer_api/pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions.ts @@ -1,7 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later type ElasticsearchFieldType = 'text' | 'keyword' | 'boolean' | 'long' | 'integer' | 'date' | 'float'; -export type FluxerSearchIndexName = 'messages' | 'guilds' | 'users' | 'reports' | 'audit_logs' | 'guild_members'; +export type FluxerSearchIndexName = + | 'messages' + | 'guilds' + | 'users' + | 'reports' + | 'audit_logs' + | 'guild_members' + | 'threads'; export interface ElasticsearchFieldMapping { type: ElasticsearchFieldType; @@ -173,6 +180,26 @@ export const ELASTICSEARCH_INDEX_DEFINITIONS: Record { + const clauses: Array = [ + esTermFilter('guildId', filters.guildId), + esTermFilter('parentId', filters.parentId), + ]; + if (filters.publicOnly) { + clauses.push( + filters.privateThreadIds && filters.privateThreadIds.length > 0 + ? { + bool: { + should: [esTermsFilter('type', [10, 11]), esTermsFilter('id', filters.privateThreadIds)], + minimum_should_match: 1, + }, + } + : esTermsFilter('type', [10, 11]), + ); + } + if (filters.archived !== undefined) clauses.push(esTermFilter('archived', filters.archived)); + if (filters.tagIds && filters.tagIds.length > 0) { + if (filters.tagSetting === 'match_all') clauses.push(...esAndTerms('appliedTagIds', filters.tagIds)); + else clauses.push(esTermsFilter('appliedTagIds', filters.tagIds)); + } + if (filters.after) clauses.push(cursorFilter(filters.after, 'gt')); + if (filters.before) clauses.push(cursorFilter(filters.before, 'lt')); + return compactFilters(clauses); +} + +function buildThreadSort(filters: ThreadSearchFilters): Array | undefined { + const sortBy = filters.sortBy ?? 'last_message_time'; + if (sortBy === 'relevance') return undefined; + const order = filters.sortOrder ?? 'desc'; + return [...new Set([SORT_FIELDS[sortBy], 'createdAt', 'idSequence'])].map((field) => ({[field]: {order}})); +} + +export interface ElasticsearchThreadAdapterOptions { + client: Client; + lock?: ElasticsearchDistributedLock; +} + +export class ElasticsearchThreadAdapter extends ElasticsearchIndexAdapter { + constructor(options: ElasticsearchThreadAdapterOptions) { + super({ + client: options.client, + index: ELASTICSEARCH_INDEX_DEFINITIONS.threads, + searchableFields: ['name'], + buildFilters: buildThreadFilters, + buildSort: buildThreadSort, + lock: options.lock, + }); + } +} diff --git a/fluxer_api/src/api/App.ts b/fluxer_api/src/api/App.ts index fb88fab82..112fce189 100644 --- a/fluxer_api/src/api/App.ts +++ b/fluxer_api/src/api/App.ts @@ -19,6 +19,7 @@ import {Hono} from 'hono'; interface CreateAPIAppOptions { config: APIConfig; logger: ILogger; + registerRoutes?: (routes: HonoApp) => void; } interface APIAppResult { @@ -53,6 +54,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise({strict: true}); const {middleware: metricsMiddleware, metricsHandler} = createMetricsMiddleware('api'); app.use('*', metricsMiddleware); diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index c02a6bf67..efeaac18d 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -495,6 +495,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { laneName: apiWorkerConfig?.lane, taskName: apiWorkerConfig?.task as WorkerTaskName | undefined, enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler, + metricsPort: apiWorkerConfig?.metrics_port, laneConcurrencyOverrides: { realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime, unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl, diff --git a/fluxer_api/src/api/SearchFactory.ts b/fluxer_api/src/api/SearchFactory.ts index 555ca92a2..bc8eadab9 100644 --- a/fluxer_api/src/api/SearchFactory.ts +++ b/fluxer_api/src/api/SearchFactory.ts @@ -12,6 +12,7 @@ import type {IGuildSearchService} from '@app/api/search/IGuildSearchService'; import type {IMessageSearchService} from '@app/api/search/IMessageSearchService'; import type {IReportSearchService} from '@app/api/search/IReportSearchService'; import type {ISearchProvider} from '@app/api/search/ISearchProvider'; +import type {IThreadSearchService} from '@app/api/search/IThreadSearchService'; import type {IUserSearchService} from '@app/api/search/IUserSearchService'; import {DEFAULT_SEARCH_CLIENT_TIMEOUT_MS} from '@fluxer/constants/src/Timeouts'; import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter'; @@ -87,6 +88,10 @@ export function getGuildMemberSearchService(): IGuildMemberSearchService | null return searchProvider?.getGuildMemberSearchService() ?? null; } +export function getThreadSearchService(): IThreadSearchService | null { + return searchProvider?.getThreadSearchService() ?? null; +} + export async function initializeSearch(lock?: ElasticsearchDistributedLock): Promise { if (searchProvider) { await shutdownSearch(); diff --git a/fluxer_api/src/api/Tables.ts b/fluxer_api/src/api/Tables.ts index b43546cc4..a59f51bf3 100644 --- a/fluxer_api/src/api/Tables.ts +++ b/fluxer_api/src/api/Tables.ts @@ -141,6 +141,8 @@ import { type InviteRow, PRIVATE_CHANNEL_COLUMNS, type PrivateChannelRow, + READ_STATE_COLUMNS, + type ReadStateRow, WEBHOOK_COLUMNS, WEBHOOKS_BY_SOURCE_CHANNEL_COLUMNS, type WebhookRow, @@ -268,6 +270,30 @@ import { type StorePurchaseByUserRow, type StorePurchaseRow, } from '@app/api/database/types/StoreBillingTypes'; +import { + ACTIVE_THREADS_BY_GUILD_COLUMNS, + type ActiveThreadsByGuildRow, + ARCHIVED_THREADS_BY_PARENT_COLUMNS, + type ArchivedThreadsByParentRow, + FORUM_PINNED_THREAD_COLUMNS, + type ForumPinnedThreadRow, + GUILD_THREAD_STATE_COLUMNS, + type GuildThreadStateRow, + THREAD_MEMBER_COLUMNS, + THREAD_MEMBERS_BY_USER_COLUMNS, + THREAD_ONLY_CHANNELS_BY_GUILD_COLUMNS, + THREAD_PARENT_CONFIG_COLUMNS, + THREAD_STATE_COLUMNS, + THREAD_STATS_COLUMNS, + THREADS_BY_PARENT_COLUMNS, + type ThreadMemberRow, + type ThreadMembersByUserRow, + type ThreadOnlyChannelsByGuildRow, + type ThreadParentConfigRow, + type ThreadStateRow, + type ThreadStatsRow, + type ThreadsByParentRow, +} from '@app/api/database/types/ThreadTypes'; import { FAVORITE_MEME_COLUMNS, type FavoriteMemeRow, @@ -579,6 +605,84 @@ export const DmStates = defineTable({ + name: 'thread_state', + columns: THREAD_STATE_COLUMNS, + primaryKey: ['thread_id'], + partitionKey: ['thread_id'], +}); +export const ThreadStats = defineTable({ + name: 'thread_stats', + columns: THREAD_STATS_COLUMNS, + primaryKey: ['thread_id'], + partitionKey: ['thread_id'], +}); +export const ThreadsByParent = defineTable({ + name: 'threads_by_parent', + columns: THREADS_BY_PARENT_COLUMNS, + primaryKey: ['parent_id', 'thread_id'], + partitionKey: ['parent_id'], +}); +export const ActiveThreadsByGuild = defineTable({ + name: 'active_threads_by_guild', + columns: ACTIVE_THREADS_BY_GUILD_COLUMNS, + primaryKey: ['guild_id', 'thread_id'], + partitionKey: ['guild_id'], +}); +export const ArchivedThreadsByParent = defineTable< + ArchivedThreadsByParentRow, + 'parent_id' | 'is_private' | 'archive_timestamp' | 'thread_id', + 'parent_id' | 'is_private' +>({ + name: 'archived_threads_by_parent', + columns: ARCHIVED_THREADS_BY_PARENT_COLUMNS, + primaryKey: ['parent_id', 'is_private', 'archive_timestamp', 'thread_id'], + partitionKey: ['parent_id', 'is_private'], +}); +export const ThreadMembers = defineTable({ + name: 'thread_members', + columns: THREAD_MEMBER_COLUMNS, + primaryKey: ['thread_id', 'user_id'], + partitionKey: ['thread_id'], +}); +export const ThreadMembersByUser = defineTable< + ThreadMembersByUserRow, + 'user_id' | 'guild_id' | 'parent_id' | 'is_private' | 'thread_id', + 'user_id' +>({ + name: 'thread_members_by_user', + columns: THREAD_MEMBERS_BY_USER_COLUMNS, + primaryKey: ['user_id', 'guild_id', 'parent_id', 'is_private', 'thread_id'], + partitionKey: ['user_id'], +}); +export const ThreadParentConfig = defineTable({ + name: 'thread_parent_config', + columns: THREAD_PARENT_CONFIG_COLUMNS, + primaryKey: ['guild_id', 'channel_id'], + partitionKey: ['guild_id'], +}); +export const ForumPinnedThread = defineTable({ + name: 'forum_pinned_thread', + columns: FORUM_PINNED_THREAD_COLUMNS, + primaryKey: ['parent_id'], + partitionKey: ['parent_id'], +}); +export const ThreadOnlyChannelsByGuild = defineTable< + ThreadOnlyChannelsByGuildRow, + 'guild_id' | 'channel_id', + 'guild_id' +>({ + name: 'thread_only_channels_by_guild', + columns: THREAD_ONLY_CHANNELS_BY_GUILD_COLUMNS, + primaryKey: ['guild_id', 'channel_id'], + partitionKey: ['guild_id'], +}); +export const GuildThreadState = defineTable({ + name: 'guild_thread_state', + columns: GUILD_THREAD_STATE_COLUMNS, + primaryKey: ['guild_id'], + partitionKey: ['guild_id'], +}); interface PinnedDmRow { user_id: bigint; @@ -593,12 +697,6 @@ export const PinnedDms = defineTable({ primaryKey: ['user_id', 'channel_id'], }); -interface ReadStateRow { - user_id: bigint; - channel_id: bigint; -} - -const READ_STATE_COLUMNS = ['user_id', 'channel_id'] as const satisfies ReadonlyArray; export const ReadStates = defineTable({ name: 'read_states', columns: READ_STATE_COLUMNS, diff --git a/fluxer_api/src/api/admin/AdminAuditActions.ts b/fluxer_api/src/api/admin/AdminAuditActions.ts index f0e8ba910..eecc163be 100644 --- a/fluxer_api/src/api/admin/AdminAuditActions.ts +++ b/fluxer_api/src/api/admin/AdminAuditActions.ts @@ -38,6 +38,7 @@ export const AdminAuditReadActions = { LIST_GUILD_MEMBERS: 'list_guild_members', LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats', LIST_GUILD_STICKERS: 'list_guild_stickers', + LIST_GUILD_THREADS: 'list_guild_threads', LIST_USER_APPLICATIONS: 'list_user_applications', LIST_USER_CHANGE_LOG: 'list_user_change_log', LIST_USER_DM_CHANNELS: 'list_user_dm_channels', diff --git a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts index ed2452509..3a0d1726d 100644 --- a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts @@ -4,6 +4,7 @@ import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions'; import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder'; import {createUserID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; +import {enqueueRebuildThreadAutoArchiveQueue, enqueueThreadSearchBackfill} from '@app/api/channel/threads/ThreadJobs'; import { type InstancePolicyConfig, REGISTRATION_PENDING_APPROVAL_TRAIT, @@ -14,6 +15,7 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import { + getChannelThreadsConfigPublisher, getGatewayRolloutConfigPublisher, getInstanceConfigRepository, getPushRelayConfigPublisher, @@ -21,6 +23,11 @@ import { import {RateLimitConfigs} from '@app/api/RateLimitConfig'; import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv'; import {Validator} from '@app/api/Validator'; +import { + enabledThreadGuildIds, + enqueueThreadPermissionSeeds, + newlyEnabledThreadGuildIds, +} from '@app/api/worker/tasks/SeedThreadPermissions'; import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError'; @@ -35,6 +42,10 @@ import { PendingRegistrationActionRequest, RegistrationUrlIdParam, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import { + applyChannelThreadsConfigUpdate, + type ChannelThreadsConfig, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas'; import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas'; import {PlutoniumPageConfigSchema} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas'; @@ -69,6 +80,7 @@ async function buildInstanceConfigResponse(): Promise { domainMigration, plutoniumPage, captcha, + channelThreads, experimentDelivery, registrationConfig, registrationUrls, @@ -80,6 +92,7 @@ async function buildInstanceConfigResponse(): Promise { instanceConfigRepository.getDomainMigrationConfig(), instanceConfigRepository.getPlutoniumPageConfig(), instanceConfigRepository.getCaptchaConfig(), + instanceConfigRepository.getChannelThreadsConfig(), instanceConfigRepository.getExperimentDeliveryConfig(), instanceConfigRepository.getRegistrationConfig(), instanceConfigRepository.getRegistrationUrlsForAdmin(), @@ -118,6 +131,7 @@ async function buildInstanceConfigResponse(): Promise { domain_migration: domainMigration, plutonium_page: plutoniumPage, captcha, + channel_threads: channelThreads, experiment_delivery: experimentDelivery, registration: { ...registrationConfig, @@ -418,6 +432,25 @@ export function InstanceConfigAdminController(app: HonoApp) { await instanceConfigRepository.updateCaptchaConfig(patch); } } + let channelThreadsConfigVersion: number | undefined; + if (data.channel_threads) { + const patch = omitUndefinedFields(data.channel_threads); + if (Object.keys(patch).length > 0) { + let previous: ChannelThreadsConfig | undefined; + const landed = await instanceConfigRepository.updateChannelThreadsConfig((current) => { + previous = current; + return applyChannelThreadsConfigUpdate(current, patch); + }); + channelThreadsConfigVersion = landed.config_version; + await enqueueThreadPermissionSeeds(ctx.get('channelRepository').threads, landed); + const newlyEnabled = previous ? newlyEnabledThreadGuildIds(previous, landed) : enabledThreadGuildIds(landed); + for (const guildId of newlyEnabled) { + await enqueueRebuildThreadAutoArchiveQueue(guildId); + await enqueueThreadSearchBackfill(guildId); + } + await getChannelThreadsConfigPublisher().publish(landed); + } + } if (data.experiment_delivery) { const patch = data.experiment_delivery; await instanceConfigRepository.updateExperimentDeliveryConfig((current) => @@ -613,6 +646,7 @@ export function InstanceConfigAdminController(app: HonoApp) { action: 'update_instance_config', metadata: { sections: listSuppliedSections(data), + channel_threads_config_version: channelThreadsConfigVersion?.toString(), granted_acls: grantedSetupCompleterAdmin ? AdminACLs.WILDCARD : undefined, }, }); diff --git a/fluxer_api/src/api/admin/controllers/SearchAdminController.ts b/fluxer_api/src/api/admin/controllers/SearchAdminController.ts index 6c3714da6..7266c6509 100644 --- a/fluxer_api/src/api/admin/controllers/SearchAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/SearchAdminController.ts @@ -26,7 +26,7 @@ export function SearchAdminController(app: HonoApp) { operationId: 'create_admin_search_index_refresh', summary: 'Refresh a search index', description: - 'Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages and guild_members indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.', + 'Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages, guild_members and threads indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.', responseSchema: RefreshSearchIndexResponse, statusCode: 200, security: 'adminApiKey', diff --git a/fluxer_api/src/api/admin/controllers/ThreadAdminController.ts b/fluxer_api/src/api/admin/controllers/ThreadAdminController.ts new file mode 100644 index 000000000..5e872afa8 --- /dev/null +++ b/fluxer_api/src/api/admin/controllers/ThreadAdminController.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions'; +import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder'; +import {createChannelID, createGuildID} from '@app/api/BrandedTypes'; +import {requireAdminACL} from '@app/api/middleware/AdminMiddleware'; +import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; +import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; +import {AdminRateLimitConfigs} from '@app/api/rate_limit_configs/AdminRateLimitConfig'; +import type {HonoApp} from '@app/api/types/HonoEnv'; +import {Validator} from '@app/api/Validator'; +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; +import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; +import {ListGuildThreadsResponse} from '@fluxer/schema/src/domains/admin/AdminThreadSchemas'; +import {ChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; + +export function ThreadAdminController(app: HonoApp) { + app.get( + '/admin/guilds/:guild_id/threads', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.GUILD_LOOKUP), + Validator('param', GuildIdParam), + OpenAPI({ + operationId: 'list_admin_guild_threads', + summary: 'List guild threads', + description: + 'Lists every thread of a guild, active and archived, whether or not the channel threads experiment is active for it. Requires GUILD_LOOKUP permission.', + responseSchema: ListGuildThreadsResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + experiment: 'channel_threads', + }), + async (ctx) => { + const guildId = createGuildID(ctx.req.valid('param').guild_id); + const threads = await ctx.get('threadService').lists.listGuildThreadsForAdmin(guildId); + await recordAdminRead(ctx, { + targetType: 'guild', + targetId: guildId, + action: AdminAuditReadActions.LIST_GUILD_THREADS, + metadata: {result_count: threads.length}, + }); + return ctx.json({threads}); + }, + ); + app.delete( + '/admin/channels/:channel_id', + RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_MESSAGE_OPERATION), + requireAdminACL(AdminACLs.MESSAGE_DELETE_ALL), + Validator('param', ChannelIdParam), + OpenAPI({ + operationId: 'delete_admin_thread_channel', + summary: 'Delete a thread', + description: + 'Deletes a thread channel with its messages and memberships. Only public and private threads can be deleted here. Requires MESSAGE_DELETE_ALL permission.', + responseSchema: null, + statusCode: 204, + security: 'adminApiKey', + tags: 'Admin', + experiment: 'channel_threads', + }), + async (ctx) => { + const channelId = createChannelID(ctx.req.valid('param').channel_id); + const thread = await ctx.get('channelRepository').findUnique(channelId); + if (!thread) throw new UnknownChannelError(); + if (!thread.isThread()) throw new InvalidChannelTypeError(); + const adminUserId = ctx.get('adminUserId'); + await ctx.get('threadService').deletion.deleteThread({ + thread, + actorId: adminUserId, + auditLogReason: ctx.get('auditLogReason'), + recordGuildAudit: false, + }); + await recordAdminWrite(ctx, { + targetType: 'channel', + targetId: channelId, + action: 'delete_thread', + metadata: {guild_id: thread.guildId?.toString(), parent_id: thread.parentId?.toString(), type: thread.type}, + }); + return ctx.body(null, 204); + }, + ); +} diff --git a/fluxer_api/src/api/admin/controllers/index.ts b/fluxer_api/src/api/admin/controllers/index.ts index 5c2726b98..283531654 100644 --- a/fluxer_api/src/api/admin/controllers/index.ts +++ b/fluxer_api/src/api/admin/controllers/index.ts @@ -19,6 +19,7 @@ import {ReportAdminController} from '@app/api/admin/controllers/ReportAdminContr import {SearchAdminController} from '@app/api/admin/controllers/SearchAdminController'; import {StoreBillingAdminController} from '@app/api/admin/controllers/StoreBillingAdminController'; import {SystemDmAdminController} from '@app/api/admin/controllers/SystemDmAdminController'; +import {ThreadAdminController} from '@app/api/admin/controllers/ThreadAdminController'; import {UserAdminController} from '@app/api/admin/controllers/UserAdminController'; import {VoiceAdminController} from '@app/api/admin/controllers/VoiceAdminController'; import type {HonoApp} from '@app/api/types/HonoEnv'; @@ -30,6 +31,7 @@ export function registerAdminControllers(app: HonoApp) { StoreBillingAdminController(app); CodesAdminController(app); GuildAdminController(app); + ThreadAdminController(app); AssetAdminController(app); BanAdminController(app); InstanceConfigAdminController(app); diff --git a/fluxer_api/src/api/admin/services/AdminMessageService.ts b/fluxer_api/src/api/admin/services/AdminMessageService.ts index 96780cb1b..1a07a86c8 100644 --- a/fluxer_api/src/api/admin/services/AdminMessageService.ts +++ b/fluxer_api/src/api/admin/services/AdminMessageService.ts @@ -13,17 +13,19 @@ import { type UserID, } from '@app/api/BrandedTypes'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; +import {withThreadContext} from '@app/api/channel/services/ChannelGatewayDispatch'; import { enqueueCrosspostFamilyPurgeFromCopies, enqueueCrosspostSourceRemoval, } from '@app/api/channel/services/message/CrosspostPropagation'; -import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers'; +import {decrementThreadMessageCount, purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers'; import { createMessageResponseDataService, type MessageResponseAccessContext, messageResponseAccessForChannel, messageResponseAccessForGuild, } from '@app/api/channel/services/message/MessageResponseDataService'; +import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope'; import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import {getPurgeQueue, getStorageService} from '@app/api/middleware/ServiceSingletons'; @@ -144,15 +146,16 @@ export class AdminMessageService { message.authorId || createUserID(0n), message.pinnedTimestamp || undefined, ); + await decrementThreadMessageCount(channelRepository, channel, [messageId]); if (channel) { if (channel.guildId) { await gatewayService.dispatchGuild({ guildId: channel.guildId, event: 'MESSAGE_DELETE', - data: { + data: withThreadContext(channel, { channel_id: channelId.toString(), id: messageId.toString(), - }, + }), }); } else { for (const recipientId of channel.recipientIds) { @@ -349,9 +352,12 @@ export class AdminMessageService { guildName: null, }; } - const guild = await guildRepository.findUnique(channel.guildId); + const [guild, scope] = await Promise.all([ + guildRepository.findUnique(channel.guildId), + resolveNsfwScopeChannel(channel, (id) => channelRepository.findUnique(id)), + ]); return { - channelNsfw: channel.isNsfw, + channelNsfw: scope.isNsfw, guildNsfwLevel: guild?.nsfwLevel ?? null, channelName: channel.name ?? null, guildId: channel.guildId.toString(), diff --git a/fluxer_api/src/api/admin/services/AdminReportService.ts b/fluxer_api/src/api/admin/services/AdminReportService.ts index 07bd749f6..444835573 100644 --- a/fluxer_api/src/api/admin/services/AdminReportService.ts +++ b/fluxer_api/src/api/admin/services/AdminReportService.ts @@ -21,9 +21,11 @@ import { messageResponseAccessForChannel, messageResponseAccessForGuild, } from '@app/api/channel/services/message/MessageResponseDataService'; +import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope'; import {SYSTEM_USER_ID} from '@app/api/constants/Core'; import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService'; import type {MessageAttachment} from '@app/api/database/types/MessageTypes'; +import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import type {IStorageService} from '@app/api/infrastructure/IStorageService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; @@ -205,6 +207,7 @@ export class AdminReportService { }); await this.deps.channelService.messages.send.sendMessage({ user: systemUser, + viewer: SYSTEM_THREAD_VIEWER, channelId: dmChannel.id, data: { content: template.value.body, @@ -523,7 +526,10 @@ export class AdminReportService { return reportNsfwLookupCache.channelNsfwByChannelId.get(channelIdString) ?? null; } const channel = await this.deps.channelRepository.findUnique(channelId); - const channelNsfw = channel?.isNsfw ?? null; + const scope = channel + ? await resolveNsfwScopeChannel(channel, (id) => this.deps.channelRepository.findUnique(id)) + : null; + const channelNsfw = scope?.isNsfw ?? null; reportNsfwLookupCache.channelNsfwByChannelId.set(channelIdString, channelNsfw); return channelNsfw; } diff --git a/fluxer_api/src/api/admin/services/AdminSearchService.ts b/fluxer_api/src/api/admin/services/AdminSearchService.ts index b9b9a2613..195c6b60c 100644 --- a/fluxer_api/src/api/admin/services/AdminSearchService.ts +++ b/fluxer_api/src/api/admin/services/AdminSearchService.ts @@ -6,9 +6,11 @@ import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes'; import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'; import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes'; import {isSyntheticUserId} from '@app/api/constants/Core'; +import {channelThreadsEnabled} from '@app/api/experiment/ChannelThreadsGate'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import {Logger} from '@app/api/Logger'; import {getGuildSearchService, getUserSearchService} from '@app/api/SearchFactory'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import type {UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes'; @@ -23,7 +25,8 @@ interface RefreshSearchIndexJobPayload extends WorkerJobPayload { | 'channel_messages' | 'favorite_memes' | 'guild_members' - | 'discovery'; + | 'discovery' + | 'threads'; admin_user_id: string; audit_log_reason: string | null; job_id: string; @@ -175,7 +178,8 @@ export class AdminSearchService { | 'channel_messages' | 'guild_members' | 'favorite_memes' - | 'discovery'; + | 'discovery' + | 'threads'; guild_id?: bigint; user_id?: bigint; }, @@ -197,6 +201,15 @@ export class AdminSearchService { } payload.guild_id = data.guild_id.toString(); } + if (data.index_type === 'threads') { + if (!channelThreadsEnabled()) { + throw InputValidationError.fromCode('index_name', ValidationErrorCodes.INVALID_FORMAT); + } + if (!data.guild_id) { + throw InputValidationError.create('guild_id', 'guild_id is required for the threads index type'); + } + payload.guild_id = data.guild_id.toString(); + } if (data.index_type === 'guild_members') { if (!data.guild_id) { throw InputValidationError.create('guild_id', 'guild_id is required for the guild_members index type'); diff --git a/fluxer_api/src/api/admin/services/guild/AdminGuildLookupService.ts b/fluxer_api/src/api/admin/services/guild/AdminGuildLookupService.ts index c582b3bc0..6c89452ad 100644 --- a/fluxer_api/src/api/admin/services/guild/AdminGuildLookupService.ts +++ b/fluxer_api/src/api/admin/services/guild/AdminGuildLookupService.ts @@ -39,7 +39,7 @@ export class AdminGuildLookupService { return {guild: null}; } const [channels, roles, ownerUser] = await Promise.all([ - channelRepository.listGuildChannels(guildId), + channelRepository.listGuildChannels(guildId, 'maintenance'), guildRepository.listRoles(guildId), userRepository.findUnique(guild.ownerId), ]); diff --git a/fluxer_api/src/api/admin/tests/AdminThreadBrowse.test.ts b/fluxer_api/src/api/admin/tests/AdminThreadBrowse.test.ts new file mode 100644 index 000000000..3728d84a7 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/AdminThreadBrowse.test.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +interface BrowseResponse { + messages: Array<{id: string; channel_id: string}>; + message_responses?: Array; +} + +describe('admin thread browse', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + it('returns thread messages and thread artifacts unmasked', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + await ensureSessionStarted(harness, owner.token); + const guild = await createGuild(harness, owner.token, 'browse'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const source = await sendMessage(harness, owner.token, channel.id, 'source'); + await threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/messages/${source.id}/threads`) + .body({name: 'from source'}) + .expect(201) + .execute(); + const standalone = await threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'standalone', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + const inThread = await threadsRequest(harness, owner.token) + .post(`/channels/${standalone.id}/messages`) + .body({content: 'inside'}) + .expect(200) + .execute(); + const admin = await setUserACLs(harness, await createTestAccount(harness), [ + AdminACLs.AUTHENTICATE, + AdminACLs.MESSAGE_LOOKUP, + ]); + const parent = await createBuilder(harness, admin.token) + .get(`/admin/channels/${channel.id}/messages?limit=50`) + .expect(200) + .execute(); + const responses = parent.message_responses ?? []; + const sourceResponse = responses.find((message) => message.id === source.id); + expect((sourceResponse?.flags ?? 0) & ServerMessageFlags.HAS_THREAD).toBe(ServerMessageFlags.HAS_THREAD); + expect(responses.some((message) => message.type === MessageTypes.THREAD_CREATED)).toBe(true); + const thread = await createBuilder(harness, admin.token) + .get(`/admin/channels/${standalone.id}/messages?limit=50`) + .expect(200) + .execute(); + expect(thread.messages.map((message) => message.id)).toContain(inThread.id); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/audit_coverage/AdminAuditRouteCoverage.test.ts b/fluxer_api/src/api/admin/tests/audit_coverage/AdminAuditRouteCoverage.test.ts index 50898c17f..a4e69713b 100644 --- a/fluxer_api/src/api/admin/tests/audit_coverage/AdminAuditRouteCoverage.test.ts +++ b/fluxer_api/src/api/admin/tests/audit_coverage/AdminAuditRouteCoverage.test.ts @@ -21,6 +21,7 @@ import {ReportAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ReportA import {SearchAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SearchAdminAuditCases'; import {StoreBillingAdminAuditCases} from '@app/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases'; import {SystemDmAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SystemDmAdminAuditCases'; +import {ThreadAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ThreadAdminAuditCases'; import {UserAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserAdminAuditCases'; import {UserWriteAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserWriteAdminAuditCases'; import {VoiceAdminAuditCases} from '@app/api/admin/tests/audit_coverage/VoiceAdminAuditCases'; @@ -48,6 +49,7 @@ const ALL_CASES = [ ...SearchAdminAuditCases, ...StoreBillingAdminAuditCases, ...SystemDmAdminAuditCases, + ...ThreadAdminAuditCases, ...UserAdminAuditCases, ...UserWriteAdminAuditCases, ...VoiceAdminAuditCases, diff --git a/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts b/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts index 87e8e141f..38ef7bede 100644 --- a/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts +++ b/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts @@ -8,11 +8,13 @@ import { type TestAccount, } from '@app/api/auth/tests/AuthTestUtils'; import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils'; +import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher'; import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; import type {ApiTestHarness} from '@app/api/test/ApiTestHarness'; import {HTTP_STATUS, TEST_IDS} from '@app/api/test/TestConstants'; import {createBuilder} from '@app/api/test/TestRequestBuilder'; import type {CreateRegistrationUrlResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import {vi} from 'vitest'; async function createRegistrationUrl(harness: ApiTestHarness, admin: TestAccount): Promise { const created = await createBuilder(harness, admin.token) @@ -80,6 +82,26 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray = [ + { + method: 'GET', + route: '/admin/guilds/:guild_id/threads', + async prepare({harness, admin}) { + resetChannelThreadsConfig(); + const guild = await createGuild(harness, admin.token, 'Audit Thread List Guild'); + return { + request: {path: `/admin/guilds/${guild.id}/threads`}, + expected: { + action: 'list_guild_threads', + targetType: 'guild', + targetId: guild.id, + metadata: {result_count: '0'}, + }, + }; + }, + }, + { + method: 'DELETE', + route: '/admin/channels/:channel_id', + async prepare({harness, admin}) { + resetChannelThreadsConfig(); + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const guild = await createGuild(harness, admin.token, 'Audit Thread Delete Guild'); + const channel = await createChannel(harness, admin.token, guild.id, 'general'); + const thread = await threadsRequest(harness, admin.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'doomed', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + return { + request: {path: `/admin/channels/${thread.id}`, expectStatus: 204}, + expected: { + action: 'delete_thread', + targetType: 'channel', + targetId: thread.id, + metadata: {guild_id: guild.id, parent_id: channel.id, type: String(ChannelTypes.PUBLIC_THREAD)}, + }, + }; + }, + }, +]; diff --git a/fluxer_api/src/api/admin/tests/audit_coverage/ThreadAdminAuditCoverage.test.ts b/fluxer_api/src/api/admin/tests/audit_coverage/ThreadAdminAuditCoverage.test.ts new file mode 100644 index 000000000..9f9eb26ca --- /dev/null +++ b/fluxer_api/src/api/admin/tests/audit_coverage/ThreadAdminAuditCoverage.test.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage'; +import {ThreadAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ThreadAdminAuditCases'; + +describeAdminAuditCoverage('ThreadAdminController', ThreadAdminAuditCases); diff --git a/fluxer_api/src/api/app/MiddlewarePipeline.ts b/fluxer_api/src/api/app/MiddlewarePipeline.ts index f3595f606..d7dd61913 100644 --- a/fluxer_api/src/api/app/MiddlewarePipeline.ts +++ b/fluxer_api/src/api/app/MiddlewarePipeline.ts @@ -3,6 +3,7 @@ import type {ILogger} from '@app/api/ILogger'; import {ActivityContextMiddleware} from '@app/api/infrastructure/activity/ActivityMeta'; import {AuditLogMiddleware} from '@app/api/middleware/AuditLogMiddleware'; +import {ClientFeaturesMiddleware} from '@app/api/middleware/ClientFeaturesMiddleware'; import {ConcurrencyLimitMiddleware} from '@app/api/middleware/ConcurrencyLimitMiddleware'; import ContentFilterMiddleware from '@app/api/middleware/ContentFilterMiddleware'; import {GuildAvailabilityMiddleware} from '@app/api/middleware/GuildAvailabilityMiddleware'; @@ -78,6 +79,7 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline ); routes.use(RequestErrorTelemetry); routes.use(RequestCacheMiddleware); + routes.use(ClientFeaturesMiddleware); if (nodeEnv === 'production') { routes.use('*', async (ctx, next) => { const host = ctx.req.header('host'); diff --git a/fluxer_api/src/api/channel/ChannelMappers.ts b/fluxer_api/src/api/channel/ChannelMappers.ts index 1561e2b31..b7dc13278 100644 --- a/fluxer_api/src/api/channel/ChannelMappers.ts +++ b/fluxer_api/src/api/channel/ChannelMappers.ts @@ -99,6 +99,17 @@ function serializeGuildTextChannel(channel: Channel, ctx: ContentWarningCtx): Ch }; } +function serializeThreadOnlyChannel(channel: Channel, ctx: ContentWarningCtx): ChannelResponse { + return { + ...serializeBaseChannelFields(channel), + ...serializeMessageableFields(channel), + ...serializePositionableGuildChannelFields(channel), + topic: channel.topic, + ...serializeContentWarningFields(channel, ctx), + rate_limit_per_user: channel.rateLimitPerUser, + }; +} + function serializeGuildVoiceChannel(channel: Channel, ctx: ContentWarningCtx): ChannelResponse { return { ...serializeBaseChannelFields(channel), @@ -202,6 +213,10 @@ export async function mapChannelToResponse(params: MapChannelToResponseParams): case ChannelTypes.GUILD_VOICE: response = serializeGuildVoiceChannel(channel, ctx); break; + case ChannelTypes.GUILD_FORUM: + case ChannelTypes.GUILD_MEDIA: + response = serializeThreadOnlyChannel(channel, ctx); + break; case ChannelTypes.GUILD_CATEGORY: response = serializeGuildCategoryChannel(channel, ctx); break; diff --git a/fluxer_api/src/api/channel/ChannelRepository.ts b/fluxer_api/src/api/channel/ChannelRepository.ts index eae81b9e3..e408807e4 100644 --- a/fluxer_api/src/api/channel/ChannelRepository.ts +++ b/fluxer_api/src/api/channel/ChannelRepository.ts @@ -3,6 +3,8 @@ import type {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; import {IChannelRepository} from '@app/api/channel/IChannelRepository'; import {ChannelRepository as NewChannelRepository} from '@app/api/channel/repositories/ChannelRepository'; +import type {GuildChannelListMode} from '@app/api/channel/repositories/IChannelDataRepository'; +import type {UpsertMessageOptions} from '@app/api/channel/repositories/IMessageRepository'; import type {ChannelRow} from '@app/api/database/types/ChannelTypes'; import type {MessageRow} from '@app/api/database/types/MessageTypes'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; @@ -30,6 +32,10 @@ export class ChannelRepository extends IChannelRepository { return this.repository.messageInteractions; } + get threads() { + return this.repository.threads; + } + get crossposts() { return this.repository.crossposts; } @@ -42,12 +48,12 @@ export class ChannelRepository extends IChannelRepository { return this.repository.channelData.upsert(data); } - async updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise { - return this.repository.channelData.updateLastMessageId(channelId, messageId); + async updateLastMessageId(channelId: ChannelID, messageId: MessageID, opts?: {isInsert?: boolean}): Promise { + return this.repository.channelData.updateLastMessageId(channelId, messageId, opts); } - async delete(channelId: ChannelID, guildId?: GuildID): Promise { - return this.repository.channelData.delete(channelId, guildId); + async delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise { + return this.repository.channelData.delete(channelId, guildId, type); } async listMessages( @@ -63,8 +69,8 @@ export class ChannelRepository extends IChannelRepository { return this.repository.messages.getMessage(channelId, messageId); } - async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise { - return this.repository.messages.upsertMessage(data, oldData); + async upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise { + return this.repository.messages.upsertMessage(data, oldData, opts); } async deleteMessage( @@ -176,8 +182,8 @@ export class ChannelRepository extends IChannelRepository { ); } - async listGuildChannels(guildId: GuildID): Promise> { - return this.repository.channelData.listGuildChannels(guildId); + async listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise> { + return this.repository.channelData.listGuildChannels(guildId, mode); } async listChannels(channelIds: Array): Promise> { diff --git a/fluxer_api/src/api/channel/IChannelRepository.ts b/fluxer_api/src/api/channel/IChannelRepository.ts index 73b50566b..5587327ca 100644 --- a/fluxer_api/src/api/channel/IChannelRepository.ts +++ b/fluxer_api/src/api/channel/IChannelRepository.ts @@ -1,7 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; +import type {GuildChannelListMode} from '@app/api/channel/repositories/IChannelDataRepository'; import {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import type {UpsertMessageOptions} from '@app/api/channel/repositories/IMessageRepository'; import type {ChannelRow} from '@app/api/database/types/ChannelTypes'; import type {MessageRow} from '@app/api/database/types/MessageTypes'; import type {Channel} from '@app/api/models/Channel'; @@ -13,11 +15,11 @@ export abstract class IChannelRepository extends IChannelRepositoryAggregate { abstract upsert(data: ChannelRow): Promise; - abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise; + abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID, opts?: {isInsert?: boolean}): Promise; - abstract delete(channelId: ChannelID, guildId?: GuildID): Promise; + abstract delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise; - abstract listGuildChannels(guildId: GuildID): Promise>; + abstract listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise>; abstract listChannels(channelIds: Array): Promise>; @@ -32,7 +34,7 @@ export abstract class IChannelRepository extends IChannelRepositoryAggregate { abstract getMessage(channelId: ChannelID, messageId: MessageID): Promise; - abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise; + abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise; abstract deleteMessage( channelId: ChannelID, diff --git a/fluxer_api/src/api/channel/controllers/ChannelController.ts b/fluxer_api/src/api/channel/controllers/ChannelController.ts index c8f96866e..78661ba12 100644 --- a/fluxer_api/src/api/channel/controllers/ChannelController.ts +++ b/fluxer_api/src/api/channel/controllers/ChannelController.ts @@ -2,6 +2,8 @@ import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService'; import {createChannelID, createUserID} from '@app/api/BrandedTypes'; +import {GatedJsonValidator} from '@app/api/channel/threads/GatedJsonValidator'; +import {viewerActive, viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {GroupDmRecipientAddProtectionMiddleware} from '@app/api/middleware/GroupDmProtectionMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; @@ -9,13 +11,14 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware'; import {RateLimitConfigs} from '@app/api/RateLimitConfig'; import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv'; -import {CLIENT_FEATURES_HEADER, parseClientFeaturesHeader} from '@app/api/utils/featureUtils'; import {Validator} from '@app/api/Validator'; import {ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants'; +import {TEXT_THREAD_PARENT_CHANNEL_TYPES, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {ChannelTypeConversionNotSupportedError} from '@fluxer/errors/src/domains/channel/ChannelTypeConversionNotSupportedError'; import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas'; import { + ChannelUpdateGatedRequest, ChannelUpdateRequest, ChannelUpdateRequestBody, DeleteChannelQuery, @@ -33,6 +36,8 @@ import { } from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import type {Context} from 'hono'; +const THREAD_PARENT_DEFAULT_KEYS = ['default_auto_archive_duration', 'default_thread_rate_limit_per_user']; + function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -59,11 +64,7 @@ export function ChannelController(app: HonoApp) { const requestCache = ctx.get('requestCache'); const channelRequestService = ctx.get('channelRequestService'); return ctx.json( - await channelRequestService.getChannelResponse({ - userId, - channelId, - requestCache, - }), + await channelRequestService.getChannelResponse({viewer: viewerFromCtx(ctx), userId, channelId, requestCache}), ); }, ); @@ -86,7 +87,7 @@ export function ChannelController(app: HonoApp) { const user = ctx.get('user'); const channelId = createChannelID(ctx.req.valid('param').channel_id); const channelRequestService = ctx.get('channelRequestService'); - return ctx.json(await channelRequestService.getSlowmodeState({user, channelId})); + return ctx.json(await channelRequestService.getSlowmodeState({viewer: viewerFromCtx(ctx), user, channelId})); }, ); app.get( @@ -109,7 +110,7 @@ export function ChannelController(app: HonoApp) { const userId = ctx.get('user').id; const channelId = createChannelID(ctx.req.valid('param').channel_id); const channelRequestService = ctx.get('channelRequestService'); - return ctx.json(await channelRequestService.listRtcRegions({userId, channelId})); + return ctx.json(await channelRequestService.listRtcRegions({viewer: viewerFromCtx(ctx), userId, channelId})); }, ); app.patch( @@ -123,15 +124,17 @@ export function ChannelController(app: HonoApp) { } const channelId = createChannelID(result.data.channel_id); const existing = await ctx.get('channelService').channelData.operations.getChannel({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, channelId, skipNsfwValidation: true, }); ctx.set('channelUpdateType', existing.type); + ctx.set('channelUpdateGuildId', existing.guildId?.toString()); return undefined; }, }), - Validator('json', ChannelUpdateRequest, { + GatedJsonValidator(ChannelUpdateRequest, ChannelUpdateGatedRequest, { pre: async (raw: unknown, ctx: Context) => { const channelType = ctx.get('channelUpdateType'); if (channelType === undefined) { @@ -152,6 +155,20 @@ export function ChannelController(app: HonoApp) { } return {...body, type: requestedType}; }, + touchesGate: (body, ctx) => { + const channelType = ctx.get('channelUpdateType'); + if (channelType === undefined) return false; + if (THREAD_ONLY_CHANNEL_TYPES.has(channelType)) return true; + return ( + TEXT_THREAD_PARENT_CHANNEL_TYPES.has(channelType) && + isPlainObject(body) && + THREAD_PARENT_DEFAULT_KEYS.some((key) => body[key] !== undefined) + ); + }, + active: (ctx) => { + const guildId = ctx.get('channelUpdateGuildId'); + return guildId !== undefined && viewerActive(viewerFromCtx(ctx), guildId); + }, }), OpenAPI({ operationId: 'update_channel', @@ -171,12 +188,13 @@ export function ChannelController(app: HonoApp) { const existingType = ctx.get('channelUpdateType'); const typeConversion = existingType !== undefined && data.type !== existingType ? {from: existingType, to: data.type} : null; - const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER)); + const clientFeatures = ctx.get('clientFeatures'); const requestCache = ctx.get('requestCache'); const auditLogReason = ctx.get('auditLogReason') ?? null; const channelRequestService = ctx.get('channelRequestService'); return ctx.json( await channelRequestService.updateChannel({ + viewer: viewerFromCtx(ctx), userId, channelId, data, @@ -217,14 +235,23 @@ export function ChannelController(app: HonoApp) { const requestCache = ctx.get('requestCache'); const auditLogReason = ctx.get('auditLogReason') ?? null; const channelRequestService = ctx.get('channelRequestService'); - await ctx.get('channelService').channelData.operations.getChannel({userId, channelId}); + await ctx + .get('channelService') + .channelData.operations.getChannel({viewer: viewerFromCtx(ctx), userId, channelId}); if (delete_messages) { await requireSudoMode(ctx, user, body); await ctx.get('channelService').userMessageDeletion.deleteUserMessagesInScope(userId, { channelIds: [channelId], }); } - await channelRequestService.deleteChannel({userId, channelId, requestCache, silent, auditLogReason}); + await channelRequestService.deleteChannel({ + viewer: viewerFromCtx(ctx), + userId, + channelId, + requestCache, + silent, + auditLogReason, + }); return ctx.body(null, 204); }, ); @@ -286,7 +313,9 @@ export function ChannelController(app: HonoApp) { const body = ctx.req.valid('json'); const requestCache = ctx.get('requestCache'); if (delete_messages && recipientId === userId) { - await ctx.get('channelService').channelData.operations.getChannel({userId, channelId}); + await ctx + .get('channelService') + .channelData.operations.getChannel({viewer: viewerFromCtx(ctx), userId, channelId}); await requireSudoMode(ctx, ctx.get('user'), body); await ctx.get('channelService').userMessageDeletion.deleteUserMessagesInScope(userId, { channelIds: [channelId], @@ -319,7 +348,7 @@ export function ChannelController(app: HonoApp) { const channelId = createChannelID(ctx.req.valid('param').channel_id); const overwriteId = ctx.req.valid('param').overwrite_id; const data = ctx.req.valid('json'); - const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER)); + const clientFeatures = ctx.get('clientFeatures'); const requestCache = ctx.get('requestCache'); const auditLogReason = ctx.get('auditLogReason') ?? null; await ctx.get('channelService').channelData.operations.setChannelPermissionOverwrite({ @@ -332,6 +361,7 @@ export function ChannelController(app: HonoApp) { deny_: data.deny ? data.deny : 0n, }, clientFeatures, + viewer: viewerFromCtx(ctx), requestCache, auditLogReason, }); @@ -363,6 +393,8 @@ export function ChannelController(app: HonoApp) { userId, channelId, overwriteId, + clientFeatures: ctx.get('clientFeatures'), + viewer: viewerFromCtx(ctx), requestCache, auditLogReason, }); diff --git a/fluxer_api/src/api/channel/controllers/ChannelFollowController.ts b/fluxer_api/src/api/channel/controllers/ChannelFollowController.ts index 7e261b799..d8c2b638d 100644 --- a/fluxer_api/src/api/channel/controllers/ChannelFollowController.ts +++ b/fluxer_api/src/api/channel/controllers/ChannelFollowController.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createChannelID} from '@app/api/BrandedTypes'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; @@ -37,6 +38,7 @@ export function ChannelFollowController(app: HonoApp) { assertAccountNotLimited(ctx.get('user')); const followed = await ctx.get('channelFollowService').followChannel({ userId: ctx.get('user').id, + viewer: viewerFromCtx(ctx), channelId: createChannelID(ctx.req.valid('param').channel_id), webhookChannelId: createChannelID(ctx.req.valid('json').webhook_channel_id), requestCache: ctx.get('requestCache'), @@ -66,6 +68,7 @@ export function ChannelFollowController(app: HonoApp) { async (ctx) => { const stats = await ctx.get('channelFollowService').getFollowerStats({ userId: ctx.get('user').id, + viewer: viewerFromCtx(ctx), channelId: createChannelID(ctx.req.valid('param').channel_id), }); return ctx.json(stats); diff --git a/fluxer_api/src/api/channel/controllers/ForumController.ts b/fluxer_api/src/api/channel/controllers/ForumController.ts new file mode 100644 index 000000000..c59416b0e --- /dev/null +++ b/fluxer_api/src/api/channel/controllers/ForumController.ts @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createChannelID} from '@app/api/BrandedTypes'; +import {ChannelThreadsRouteGuard} from '@app/api/channel/threads/ChannelThreadsRouteGuard'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; +import {LoginRequired} from '@app/api/middleware/AuthMiddleware'; +import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; +import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; +import {RateLimitConfigs} from '@app/api/RateLimitConfig'; +import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv'; +import {Validator} from '@app/api/Validator'; +import {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import { + ChannelIdTagIdParam, + ForumTagRequest, + SearchIndexNotReadyResponse, + ThreadPostDataRequest, + ThreadPostDataResponse, + ThreadSearchQuery, + ThreadSearchResult, +} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas'; +import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import type {Context} from 'hono'; + +const EXPERIMENT = 'channel_threads'; +const TAGS = 'Channels'; + +function tagEditParams(ctx: Context) { + return { + userId: ctx.get('user').id, + viewer: viewerFromCtx(ctx), + clientFeatures: ctx.get('clientFeatures'), + requestCache: ctx.get('requestCache'), + auditLogReason: ctx.get('auditLogReason') ?? null, + }; +} + +export function ForumController(app: HonoApp) { + app.get( + '/channels/:channel_id/threads/search', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREADS_SEARCH), + LoginRequired, + Validator('param', ChannelIdParam), + Validator('query', ThreadSearchQuery), + OpenAPI({ + operationId: 'search_threads', + summary: 'Search threads', + description: + 'Returns threads of the channel that match the search. Requires the read message history permission. While the search index of the guild is being built, responds with 202 and a body that says when to retry.', + responseSchema: ThreadSearchResult, + acceptedResponseSchema: SearchIndexNotReadyResponse, + statusCode: [200, 202], + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + return ctx.json( + await ctx.get('threadService').forum.search({ + viewer: viewerFromCtx(ctx), + userId: ctx.get('user').id, + channelId: createChannelID(ctx.req.valid('param').channel_id), + query: ctx.req.valid('query'), + }), + ); + }, + ); + app.post( + '/channels/:channel_id/post-data', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_POST_DATA), + LoginRequired, + Validator('param', ChannelIdParam), + Validator('json', ThreadPostDataRequest), + OpenAPI({ + operationId: 'get_channel_post_data', + summary: 'Get forum post data', + description: + 'Returns the owner and first message of each requested post in a forum or media channel. Requires the read message history permission.', + responseSchema: ThreadPostDataResponse, + statusCode: 200, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + return ctx.json( + await ctx.get('threadService').forum.postData({ + viewer: viewerFromCtx(ctx), + userId: ctx.get('user').id, + channelId: createChannelID(ctx.req.valid('param').channel_id), + threadIds: ctx.req.valid('json').thread_ids.map((id) => createChannelID(id)), + requestCache: ctx.get('requestCache'), + }), + ); + }, + ); + app.post( + '/channels/:channel_id/tags', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_FORUM_TAGS), + LoginRequired, + Validator('param', ChannelIdParam), + Validator('json', ForumTagRequest), + OpenAPI({ + operationId: 'create_forum_tag', + summary: 'Create a forum tag', + description: + 'Adds a tag to a forum or media channel. Requires the manage channels permission. Returns the updated channel.', + responseSchema: ChannelResponse, + statusCode: 200, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + return ctx.json( + await ctx.get('channelRequestService').editForumTags({ + ...tagEditParams(ctx), + channelId: createChannelID(ctx.req.valid('param').channel_id), + edit: {kind: 'create', tag: ctx.req.valid('json')}, + }), + ); + }, + ); + app.put( + '/channels/:channel_id/tags/:tag_id', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_FORUM_TAGS), + LoginRequired, + Validator('param', ChannelIdTagIdParam), + Validator('json', ForumTagRequest), + OpenAPI({ + operationId: 'update_forum_tag', + summary: 'Update a forum tag', + description: + 'Replaces a tag of a forum or media channel. Requires the manage channels permission. Returns the updated channel.', + responseSchema: ChannelResponse, + statusCode: 200, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const {channel_id, tag_id} = ctx.req.valid('param'); + return ctx.json( + await ctx.get('channelRequestService').editForumTags({ + ...tagEditParams(ctx), + channelId: createChannelID(channel_id), + edit: {kind: 'update', tagId: tag_id, tag: ctx.req.valid('json')}, + }), + ); + }, + ); + app.delete( + '/channels/:channel_id/tags/:tag_id', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_FORUM_TAGS), + LoginRequired, + Validator('param', ChannelIdTagIdParam), + OpenAPI({ + operationId: 'delete_forum_tag', + summary: 'Delete a forum tag', + description: + 'Removes a tag from a forum or media channel. Requires the manage channels permission. Returns the updated channel.', + responseSchema: ChannelResponse, + statusCode: 200, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const {channel_id, tag_id} = ctx.req.valid('param'); + return ctx.json( + await ctx.get('channelRequestService').editForumTags({ + ...tagEditParams(ctx), + channelId: createChannelID(channel_id), + edit: {kind: 'delete', tagId: tag_id}, + }), + ); + }, + ); +} diff --git a/fluxer_api/src/api/channel/controllers/MessageController.ts b/fluxer_api/src/api/channel/controllers/MessageController.ts index f52f1c294..111c38914 100644 --- a/fluxer_api/src/api/channel/controllers/MessageController.ts +++ b/fluxer_api/src/api/channel/controllers/MessageController.ts @@ -6,11 +6,13 @@ import {Config} from '@app/api/Config'; import type {MessageRequest, MessageUpdateRequest} from '@app/api/channel/MessageTypes'; import {normalizeMessageRequestPayload} from '@app/api/channel/services/message/MessageRequestCompatibility'; import {parseMultipartMessageData} from '@app/api/channel/services/message/MessageRequestParser'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware'; import {RateLimitConfigs} from '@app/api/RateLimitConfig'; +import {readStateCapable} from '@app/api/read_state/ReadStateChannelMeta'; import type {HonoApp} from '@app/api/types/HonoEnv'; import {assertAccountNotLimited} from '@app/api/user/AccountLimit'; import {parseJsonPreservingLargeIntegers} from '@app/api/utils/LosslessJsonParser'; @@ -71,6 +73,7 @@ export function MessageController(app: HonoApp) { const messageRequestService = ctx.get('messageRequestService'); return ctx.json( await messageRequestService.listMessages({ + viewer: viewerFromCtx(ctx), userId, channelId, query: { @@ -108,6 +111,7 @@ export function MessageController(app: HonoApp) { const messageRequestService = ctx.get('messageRequestService'); return ctx.json( await messageRequestService.listMessagesBulk({ + viewer: viewerFromCtx(ctx), userId, requests: requests.map((request) => ({ channelId: createChannelID(request.channel_id), @@ -148,6 +152,7 @@ export function MessageController(app: HonoApp) { const messageRequestService = ctx.get('messageRequestService'); return ctx.json( await messageRequestService.getMessage({ + viewer: viewerFromCtx(ctx), userId, channelId, messageId, @@ -196,6 +201,7 @@ export function MessageController(app: HonoApp) { return validationResult.data; })(); const response = await messageRequestService.sendMessage({ + viewer: viewerFromCtx(ctx), user, channelId, data: validatedData as MessageRequest, @@ -229,6 +235,7 @@ export function MessageController(app: HonoApp) { const {attachments} = ctx.req.valid('json'); return ctx.json({ attachments: await ctx.get('channelService').attachments.requestPresignedAttachmentUploadUrls({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, channelId, clientIp, @@ -262,6 +269,7 @@ export function MessageController(app: HonoApp) { const {uploads} = ctx.req.valid('json'); return ctx.json({ uploads: await ctx.get('channelService').attachments.completeMultipartAttachmentUploads({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, channelId, clientIp, @@ -314,6 +322,7 @@ export function MessageController(app: HonoApp) { })(); return ctx.json( await messageRequestService.editMessage({ + viewer: viewerFromCtx(ctx), userId, channelId, messageId, @@ -364,9 +373,14 @@ export function MessageController(app: HonoApp) { const messageId = createMessageID(message_id); const requestCache = ctx.get('requestCache'); const auditLogReason = ctx.get('auditLogReason') ?? null; - await ctx - .get('channelService') - .messages.deletion.deleteMessage({userId, channelId, messageId, requestCache, auditLogReason}); + await ctx.get('channelService').messages.deletion.deleteMessage({ + viewer: viewerFromCtx(ctx), + userId, + channelId, + messageId, + requestCache, + auditLogReason, + }); return ctx.body(null, 204); }, ); @@ -393,6 +407,7 @@ export function MessageController(app: HonoApp) { const attachmentId = createAttachmentID(attachment_id); const requestCache = ctx.get('requestCache'); await ctx.get('channelService').attachments.deleteAttachment({ + viewer: viewerFromCtx(ctx), userId, channelId, messageId: messageId, @@ -423,9 +438,13 @@ export function MessageController(app: HonoApp) { const channelId = createChannelID(ctx.req.valid('param').channel_id); const messageIds = ctx.req.valid('json').message_ids.map(createMessageID); const auditLogReason = ctx.get('auditLogReason') ?? null; - await ctx - .get('channelService') - .messages.deletion.bulkDeleteMessages({userId, channelId, messageIds, auditLogReason}); + await ctx.get('channelService').messages.deletion.bulkDeleteMessages({ + viewer: viewerFromCtx(ctx), + userId, + channelId, + messageIds, + auditLogReason, + }); return ctx.body(null, 204); }, ); @@ -449,7 +468,7 @@ export function MessageController(app: HonoApp) { const channelId = createChannelID(ctx.req.valid('param').channel_id); const {deletedCount} = await ctx .get('channelService') - .messages.deletion.purgePersonalNotesMessages({userId, channelId}); + .messages.deletion.purgePersonalNotesMessages({viewer: viewerFromCtx(ctx), userId, channelId}); return ctx.json({deleted_count: deletedCount}); }, ); @@ -475,7 +494,9 @@ export function MessageController(app: HonoApp) { const userId = user.id; const channelId = createChannelID(ctx.req.valid('param').channel_id); const body = ctx.req.valid('json'); - await ctx.get('channelService').channelData.operations.getChannel({userId, channelId}); + await ctx + .get('channelService') + .channelData.operations.getChannel({viewer: viewerFromCtx(ctx), userId, channelId}); await requireSudoMode(ctx, user, body); await ctx.get('channelService').userMessageDeletion.deleteUserMessagesInScope(userId, { channelIds: [channelId], @@ -492,17 +513,20 @@ export function MessageController(app: HonoApp) { operationId: 'indicate_typing', summary: 'Indicate typing activity', responseSchema: null, - statusCode: 204, + statusCode: [200, 204], security: ['botToken', 'bearerToken', 'sessionToken'], tags: ['Channels', 'Messages'], description: - 'Notifies other users in the channel that you are actively typing. Typing indicators typically expire after a short period (usually 10 seconds). Returns 204 No Content. Commonly called repeatedly while the user is composing a message.', + 'Notifies other users in the channel that you are actively typing. Typing indicators typically expire after a short period (usually 10 seconds). Returns 204 No Content, or 200 with a JSON body holding the remaining slowmode cooldowns in message_send_cooldown_ms and thread_create_cooldown_ms when the user is rate limited. Commonly called repeatedly while the user is composing a message.', }), async (ctx) => { - const userId = ctx.get('user').id; + const user = ctx.get('user'); + const viewer = viewerFromCtx(ctx); const channelId = createChannelID(ctx.req.valid('param').channel_id); - await ctx.get('channelService').interactions.startTyping({userId, channelId}); - return ctx.body(null, 204); + const channelService = ctx.get('channelService'); + const auth = await channelService.interactions.startTyping({viewer, userId: user.id, channelId}); + const cooldown = await channelService.getTypingCooldown({user, viewer, auth}); + return cooldown ? ctx.json(cooldown, 200) : ctx.body(null, 204); }, ); app.post( @@ -526,6 +550,7 @@ export function MessageController(app: HonoApp) { return ctx.json( await ctx.get('messageRequestService').crosspostMessage({ userId: ctx.get('user').id, + viewer: viewerFromCtx(ctx), channelId: createChannelID(channel_id), messageId: createMessageID(message_id), requestCache: ctx.get('requestCache'), @@ -553,6 +578,7 @@ export function MessageController(app: HonoApp) { return ctx.json( await ctx.get('messageRequestService').getCrosspostSource({ userId: ctx.get('user').id, + viewer: viewerFromCtx(ctx), channelId: createChannelID(channel_id), messageId: createMessageID(message_id), requestCache: ctx.get('requestCache'), @@ -589,6 +615,7 @@ export function MessageController(app: HonoApp) { messageId, mentionCount: mentionCount ?? 0, manual, + capable: readStateCapable(ctx), }); return ctx.body(null, 204); }, diff --git a/fluxer_api/src/api/channel/controllers/MessageInteractionController.ts b/fluxer_api/src/api/channel/controllers/MessageInteractionController.ts index fa8923155..a8cfc01a2 100644 --- a/fluxer_api/src/api/channel/controllers/MessageInteractionController.ts +++ b/fluxer_api/src/api/channel/controllers/MessageInteractionController.ts @@ -3,14 +3,17 @@ import {createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes'; import {isPersonalNotesChannel} from '@app/api/channel/services/message/MessageHelpers'; import {SYSTEM_USER_ID} from '@app/api/constants/Core'; +import {THREAD_FEATURE_CHANNEL_TYPES, viewerActive, viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '@app/api/RateLimitConfig'; +import {readStateCapable} from '@app/api/read_state/ReadStateChannelMeta'; import type {HonoApp} from '@app/api/types/HonoEnv'; import {Validator} from '@app/api/Validator'; import {UserFlags} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; import {UnclaimedAccountCannotAddReactionsError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotAddReactionsError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import { @@ -53,9 +56,14 @@ export function MessageInteractionController(app: HonoApp) { const requestCache = ctx.get('requestCache'); const {limit, before} = ctx.req.valid('query'); return ctx.json( - await ctx - .get('channelService') - .interactions.getChannelPins({userId, channelId, requestCache, limit, beforeTimestamp: before}), + await ctx.get('channelService').interactions.getChannelPins({ + viewer: viewerFromCtx(ctx), + userId, + channelId, + requestCache, + limit, + beforeTimestamp: before, + }), ); }, ); @@ -77,10 +85,32 @@ export function MessageInteractionController(app: HonoApp) { async (ctx) => { const userId = ctx.get('user').id; const channelId = createChannelID(ctx.req.valid('param').channel_id); - const channel = await ctx.get('channelService').channelData.operations.getChannelSystem(channelId); + const channelService = ctx.get('channelService'); + const channel = await channelService.channelData.operations.getChannelSystem(channelId); + if (channel && THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) { + const viewer = viewerFromCtx(ctx); + if (channel.guildId === null || !viewerActive(viewer, channel.guildId)) { + return ctx.body(null, 204); + } + if (channel.isThreadOnly()) { + throw new InvalidChannelTypeError(); + } + await channelService.channelData.auth.getChannelAuthenticated({ + userId, + channelId, + viewer, + skipNsfwValidation: true, + }); + } const timestamp = channel?.lastPinTimestamp; if (timestamp != null) { - await ctx.get('readStateService').ackPins({userId, channelId, timestamp}); + await ctx.get('readStateService').ackPins({ + userId, + channelId, + timestamp, + capable: readStateCapable(ctx), + channel, + }); } return ctx.body(null, 204); }, @@ -108,6 +138,7 @@ export function MessageInteractionController(app: HonoApp) { const requestCache = ctx.get('requestCache'); const auditLogReason = ctx.get('auditLogReason') ?? null; await ctx.get('channelService').interactions.pinMessage({ + viewer: viewerFromCtx(ctx), userId, channelId, messageId, @@ -140,6 +171,7 @@ export function MessageInteractionController(app: HonoApp) { const requestCache = ctx.get('requestCache'); const auditLogReason = ctx.get('auditLogReason') ?? null; await ctx.get('channelService').interactions.unpinMessage({ + viewer: viewerFromCtx(ctx), userId, channelId, messageId, @@ -172,9 +204,15 @@ export function MessageInteractionController(app: HonoApp) { const channelId = createChannelID(channel_id); const messageId = createMessageID(message_id); const afterUserId = after ? createUserID(after) : undefined; - const result = await ctx - .get('channelService') - .interactions.getUsersForReaction({userId, channelId, messageId, emoji, limit, after: afterUserId}); + const result = await ctx.get('channelService').interactions.getUsersForReaction({ + viewer: viewerFromCtx(ctx), + userId, + channelId, + messageId, + emoji, + limit, + after: afterUserId, + }); ctx.header('X-Has-More', result.has_more ? 'true' : 'false'); if (result.next_after !== null) { ctx.header('X-Next-After', result.next_after); @@ -205,9 +243,15 @@ export function MessageInteractionController(app: HonoApp) { const channelId = createChannelID(channel_id); const messageId = createMessageID(message_id); const afterUserId = after ? createUserID(after) : undefined; - const result = await ctx - .get('channelService') - .interactions.getUsersForReaction({userId, channelId, messageId, emoji, limit, after: afterUserId}); + const result = await ctx.get('channelService').interactions.getUsersForReaction({ + viewer: viewerFromCtx(ctx), + userId, + channelId, + messageId, + emoji, + limit, + after: afterUserId, + }); return ctx.json( { items: result.users, @@ -248,6 +292,7 @@ export function MessageInteractionController(app: HonoApp) { throw InputValidationError.fromCode('emoji', ValidationErrorCodes.MUST_START_SESSION_BEFORE_SENDING); } await ctx.get('channelService').interactions.addReaction({ + viewer: viewerFromCtx(ctx), userId: user.id, sessionId, channelId, @@ -282,6 +327,7 @@ export function MessageInteractionController(app: HonoApp) { const sessionId = ctx.req.valid('query').session_id; const requestCache = ctx.get('requestCache'); await ctx.get('channelService').interactions.removeOwnReaction({ + viewer: viewerFromCtx(ctx), userId, sessionId, channelId, @@ -317,6 +363,7 @@ export function MessageInteractionController(app: HonoApp) { const sessionId = ctx.req.valid('query').session_id; const requestCache = ctx.get('requestCache'); await ctx.get('channelService').interactions.removeReaction({ + viewer: viewerFromCtx(ctx), userId, sessionId, channelId, @@ -348,7 +395,9 @@ export function MessageInteractionController(app: HonoApp) { const userId = ctx.get('user').id; const channelId = createChannelID(channel_id); const messageId = createMessageID(message_id); - await ctx.get('channelService').interactions.removeAllReactionsForEmoji({userId, channelId, messageId, emoji}); + await ctx + .get('channelService') + .interactions.removeAllReactionsForEmoji({viewer: viewerFromCtx(ctx), userId, channelId, messageId, emoji}); return ctx.body(null, 204); }, ); @@ -372,7 +421,9 @@ export function MessageInteractionController(app: HonoApp) { const userId = ctx.get('user').id; const channelId = createChannelID(channel_id); const messageId = createMessageID(message_id); - await ctx.get('channelService').interactions.removeAllReactions({userId, channelId, messageId}); + await ctx + .get('channelService') + .interactions.removeAllReactions({viewer: viewerFromCtx(ctx), userId, channelId, messageId}); return ctx.body(null, 204); }, ); diff --git a/fluxer_api/src/api/channel/controllers/StreamController.ts b/fluxer_api/src/api/channel/controllers/StreamController.ts index 954d30609..2642e6e00 100644 --- a/fluxer_api/src/api/channel/controllers/StreamController.ts +++ b/fluxer_api/src/api/channel/controllers/StreamController.ts @@ -2,6 +2,7 @@ import {createChannelID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; @@ -40,7 +41,9 @@ export function StreamController(app: HonoApp) { const user = ctx.get('user'); const {region} = ctx.req.valid('json'); const streamKey = ctx.req.valid('param').stream_key; - await ctx.get('streamService').updateStreamRegion({streamKey, region, userId: user.id}); + await ctx + .get('streamService') + .updateStreamRegion({viewer: viewerFromCtx(ctx), streamKey, region, userId: user.id}); return ctx.body(null, 204); }, ); @@ -64,7 +67,9 @@ export function StreamController(app: HonoApp) { async (ctx) => { const user = ctx.get('user'); const streamKey = ctx.req.valid('param').stream_key; - const preview = await ctx.get('streamService').getPreview({streamKey, userId: user.id}); + const preview = await ctx + .get('streamService') + .getPreview({viewer: viewerFromCtx(ctx), streamKey, userId: user.id}); if (!preview) { return ctx.body(null, 404); } @@ -102,6 +107,7 @@ export function StreamController(app: HonoApp) { clientIpHeaderName: Config.proxy.client_ip_header, }); const response = await ctx.get('streamService').createPreviewUploadUrl({ + viewer: viewerFromCtx(ctx), streamKey, channelId: createChannelID(channel_id), userId: user.id, @@ -133,6 +139,7 @@ export function StreamController(app: HonoApp) { const {thumbnail, channel_id, content_type} = ctx.req.valid('json'); const streamKey = ctx.req.valid('param').stream_key; await ctx.get('streamService').uploadPreview({ + viewer: viewerFromCtx(ctx), streamKey, channelId: createChannelID(channel_id), userId: user.id, @@ -161,7 +168,7 @@ export function StreamController(app: HonoApp) { async (ctx) => { const user = ctx.get('user'); const streamKey = ctx.req.valid('param').stream_key; - await ctx.get('streamService').deletePreview({streamKey, userId: user.id}); + await ctx.get('streamService').deletePreview({viewer: viewerFromCtx(ctx), streamKey, userId: user.id}); return ctx.body(null, 204); }, ); diff --git a/fluxer_api/src/api/channel/controllers/ThreadController.ts b/fluxer_api/src/api/channel/controllers/ThreadController.ts new file mode 100644 index 000000000..3374151e9 --- /dev/null +++ b/fluxer_api/src/api/channel/controllers/ThreadController.ts @@ -0,0 +1,530 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type ChannelID, createChannelID, createGuildID, createMessageID, createUserID} from '@app/api/BrandedTypes'; +import type {MessageRequest} from '@app/api/channel/MessageTypes'; +import {normalizeMessageRequestPayload} from '@app/api/channel/services/message/MessageRequestCompatibility'; +import {parseMultipartMessageData} from '@app/api/channel/services/message/MessageRequestParser'; +import type {ForumPostInput} from '@app/api/channel/services/thread/ThreadCreationService'; +import {ChannelThreadsRouteGuard} from '@app/api/channel/threads/ChannelThreadsRouteGuard'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; +import {BotOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; +import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; +import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; +import {RateLimitConfigs} from '@app/api/RateLimitConfig'; +import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv'; +import {parseJsonPreservingLargeIntegers} from '@app/api/utils/LosslessJsonParser'; +import {inputValidationErrorFromZodIssues, Validator} from '@app/api/Validator'; +import {TEXT_THREAD_PARENT_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; +import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; +import { + ForumThreadMessageRequest, + StartForumThreadRequest, + StartForumThreadResponse, + StartThreadMultipartRequest, + StartThreadRequestBody, +} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas'; +import { + ActiveThreadsResponse, + ArchivedThreadsQuery, + ArchivedThreadsResponse, + JoinedArchivedThreadsQuery, + StartThreadFromMessageRequest, + StartThreadRequest, + ThreadChannelResponse, + ThreadLocationQuery, + ThreadMemberGetQuery, + ThreadMemberListResponse, + ThreadMembersListQuery, +} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; +import { + ChannelIdMessageIdParam, + ChannelIdParam, + ChannelIdUserIdParam, + GuildIdParam, +} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import type {Context, MiddlewareHandler} from 'hono'; +import {z} from 'zod'; + +const EXPERIMENT = 'channel_threads'; +const TAGS = 'Channels'; + +function isMultipart(ctx: Context): boolean { + return (ctx.req.header('content-type') ?? '').includes('multipart/form-data'); +} + +async function readPayloadJson(ctx: Context): Promise { + let payloadJson: unknown; + try { + payloadJson = (await ctx.req.parseBody())['payload_json']; + } catch { + throw InputValidationError.fromCode('multipart_form', ValidationErrorCodes.FAILED_TO_PARSE_MULTIPART_FORM_DATA); + } + if (payloadJson === undefined) return {}; + if (typeof payloadJson === 'string') { + try { + return parseJsonPreservingLargeIntegers(payloadJson); + } catch {} + } + throw InputValidationError.fromCode('payload_json', ValidationErrorCodes.INVALID_JSON_IN_PAYLOAD_JSON); +} + +async function readJsonBody(ctx: Context): Promise { + if (isMultipart(ctx)) return readPayloadJson(ctx); + try { + const raw = await ctx.req.text(); + return raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw); + } catch { + throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA); + } +} + +function parseWithSchema(schema: T, value: unknown): z.output { + const result = schema.safeParse(value); + if (!result.success) throw inputValidationErrorFromZodIssues(result.error.issues); + return result.data; +} + +function SelfThreadMemberAlias(action: 'join' | 'leave'): MiddlewareHandler { + return async (ctx, next) => { + if (ctx.req.param('user_id') !== '@me') return next(); + const {channel_id} = parseWithSchema(ChannelIdParam, {channel_id: ctx.req.param('channel_id')}); + await ctx.get('threadService').members[action]({ + viewer: viewerFromCtx(ctx), + user: ctx.get('user'), + channelId: createChannelID(channel_id), + }); + return ctx.body(null, 204); + }; +} + +function normalizeForumEnvelope(value: unknown): unknown { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return value; + const envelope = value as Record; + return {...envelope, message: normalizeMessageRequestPayload(envelope.message)}; +} + +const ForumPostMultipartMessage = z.preprocess( + (value) => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? ((value as Record).message ?? {}) + : {}, + ForumThreadMessageRequest, +); + +async function parseForumPostBody(ctx: Context, channelId: ChannelID): Promise { + if (!isMultipart(ctx)) { + return parseWithSchema(StartForumThreadRequest, normalizeForumEnvelope(await readJsonBody(ctx))) as ForumPostInput; + } + let envelope: unknown = null; + const message = (await parseMultipartMessageData( + ctx, + ctx.get('user'), + channelId, + ForumPostMultipartMessage as unknown as z.ZodType, + { + onPayloadParsed(payload) { + envelope = payload; + }, + }, + )) as MessageRequest; + const fields = parseWithSchema(StartForumThreadRequest.omit({message: true}), envelope ?? {}); + return {...fields, message}; +} + +export function ThreadController(app: HonoApp) { + app.post( + '/channels/:channel_id/messages/:message_id/threads', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_CREATE), + LoginRequired, + Validator('param', ChannelIdMessageIdParam), + Validator('json', StartThreadFromMessageRequest), + OpenAPI({ + operationId: 'start_thread_from_message', + summary: 'Start a thread from a message', + description: + 'Creates a public thread from an existing message in a text channel. The thread shares the ID of the message, so a message can start one thread.', + responseSchema: ThreadChannelResponse, + statusCode: 201, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const {channel_id, message_id} = ctx.req.valid('param'); + const body = ctx.req.valid('json'); + const thread = await ctx.get('threadService').creation.createFromMessage({ + viewer: viewerFromCtx(ctx), + user: ctx.get('user'), + channelId: createChannelID(channel_id), + messageId: createMessageID(message_id), + input: { + name: body.name, + autoArchiveDuration: body.auto_archive_duration, + rateLimitPerUser: body.rate_limit_per_user, + }, + requestCache: ctx.get('requestCache'), + auditLogReason: ctx.get('auditLogReason') ?? null, + }); + return ctx.json(thread, 201); + }, + ); + app.post( + '/channels/:channel_id/threads', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_CREATE), + LoginRequired, + Validator('param', ChannelIdParam), + OpenAPI({ + operationId: 'start_thread', + summary: 'Start a thread', + description: + 'Creates a thread that is not attached to an existing message. In a text channel the thread type is required. In a forum or media channel this creates a post, and the body carries the first message. The body can also be sent as multipart form data with the JSON in a payload_json field, and a post can attach files as files[n] parts.', + requestSchema: StartThreadRequestBody, + requestFormSchema: StartThreadMultipartRequest, + responseSchema: StartForumThreadResponse, + statusCode: 201, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const viewer = viewerFromCtx(ctx); + const user = ctx.get('user'); + const channelId = createChannelID(ctx.req.valid('param').channel_id); + const creation = ctx.get('threadService').creation; + const parentAuth = await creation.authenticateParent(viewer, user.id, channelId); + const auditLogReason = ctx.get('auditLogReason') ?? null; + if (parentAuth.channel.isThreadOnly()) { + const body = await parseForumPostBody(ctx, channelId); + return ctx.json( + await creation.createForumPost({ + viewer, + user, + parentAuth, + body, + requestCache: ctx.get('requestCache'), + auditLogReason, + }), + 201, + ); + } + if (!TEXT_THREAD_PARENT_CHANNEL_TYPES.has(parentAuth.channel.type)) throw new InvalidChannelTypeError(); + const body = parseWithSchema(StartThreadRequest, await readJsonBody(ctx)); + const thread = await creation.createTextThread({ + user, + parentAuth, + type: body.type, + invitable: body.invitable, + input: { + name: body.name, + autoArchiveDuration: body.auto_archive_duration, + rateLimitPerUser: body.rate_limit_per_user, + }, + auditLogReason, + }); + return ctx.json(thread, 201); + }, + ); + app.get( + '/guilds/:guild_id/threads/active', + ChannelThreadsRouteGuard({botOnly: true}), + RateLimitMiddleware(RateLimitConfigs.GUILD_THREADS_ACTIVE), + LoginRequired, + BotOnly, + Validator('param', GuildIdParam), + OpenAPI({ + operationId: 'list_guild_active_threads', + summary: 'List active guild threads', + description: + 'Returns every active thread in the guild that the bot can view, newest first, with a thread member object for each thread the bot joined.', + responseSchema: ActiveThreadsResponse, + statusCode: 200, + security: ['botToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + return ctx.json( + await ctx.get('threadService').lists.listGuildActive({ + userId: ctx.get('user').id, + guildId: createGuildID(ctx.req.valid('param').guild_id), + }), + ); + }, + ); + app.get( + '/channels/:channel_id/threads/archived/public', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREADS_ARCHIVED_LIST), + LoginRequired, + Validator('param', ChannelIdParam), + Validator('query', ArchivedThreadsQuery), + OpenAPI({ + operationId: 'list_public_archived_threads', + summary: 'List public archived threads', + description: + 'Returns archived public threads of the channel, most recently archived first. Requires the read message history permission.', + responseSchema: ArchivedThreadsResponse, + statusCode: 200, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const {before, limit} = ctx.req.valid('query'); + return ctx.json( + await ctx.get('threadService').lists.listPublicArchived({ + viewer: viewerFromCtx(ctx), + userId: ctx.get('user').id, + channelId: createChannelID(ctx.req.valid('param').channel_id), + before: before ? new Date(before) : undefined, + limit, + }), + ); + }, + ); + app.get( + '/channels/:channel_id/threads/archived/private', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREADS_ARCHIVED_LIST), + LoginRequired, + Validator('param', ChannelIdParam), + Validator('query', ArchivedThreadsQuery), + OpenAPI({ + operationId: 'list_private_archived_threads', + summary: 'List private archived threads', + description: + 'Returns archived private threads of the text channel, most recently archived first. Requires the read message history and manage threads permissions.', + responseSchema: ArchivedThreadsResponse, + statusCode: 200, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const {before, limit} = ctx.req.valid('query'); + return ctx.json( + await ctx.get('threadService').lists.listPrivateArchived({ + viewer: viewerFromCtx(ctx), + userId: ctx.get('user').id, + channelId: createChannelID(ctx.req.valid('param').channel_id), + before: before ? new Date(before) : undefined, + limit, + }), + ); + }, + ); + app.get( + '/channels/:channel_id/users/@me/threads/archived/private', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREADS_ARCHIVED_LIST), + LoginRequired, + Validator('param', ChannelIdParam), + Validator('query', JoinedArchivedThreadsQuery), + OpenAPI({ + operationId: 'list_joined_private_archived_threads', + summary: 'List joined private archived threads', + description: + 'Returns archived private threads of the text channel that the current user joined, newest first. Requires the read message history permission.', + responseSchema: ArchivedThreadsResponse, + statusCode: 200, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const {before, limit} = ctx.req.valid('query'); + return ctx.json( + await ctx.get('threadService').lists.listJoinedPrivateArchived({ + viewer: viewerFromCtx(ctx), + userId: ctx.get('user').id, + channelId: createChannelID(ctx.req.valid('param').channel_id), + before: before !== undefined ? createChannelID(before) : undefined, + limit, + }), + ); + }, + ); + app.get( + '/channels/:channel_id/thread-members', + ChannelThreadsRouteGuard({botOnly: true}), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBERS_LIST), + LoginRequired, + BotOnly, + Validator('param', ChannelIdParam), + Validator('query', ThreadMembersListQuery), + OpenAPI({ + operationId: 'list_thread_members', + summary: 'List thread members', + description: + 'Returns thread members ordered by user ID. Paginate with after and limit. Set with_member to include the guild member object of each thread member.', + responseSchema: ThreadMemberListResponse, + statusCode: 200, + security: ['botToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const {with_member, after, limit} = ctx.req.valid('query'); + return ctx.json( + await ctx.get('threadService').members.list({ + viewer: viewerFromCtx(ctx), + user: ctx.get('user'), + channelId: createChannelID(ctx.req.valid('param').channel_id), + after: after !== undefined ? createUserID(after) : undefined, + limit, + withMember: with_member, + requestCache: ctx.get('requestCache'), + }), + ); + }, + ); + app.put( + '/channels/:channel_id/thread-members/@me', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_PUT), + LoginRequired, + Validator('param', ChannelIdParam), + Validator('query', ThreadLocationQuery), + OpenAPI({ + operationId: 'join_thread', + summary: 'Join a thread', + description: 'Adds the current user to the thread. The thread must not be archived.', + responseSchema: null, + statusCode: 204, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + await ctx.get('threadService').members.join({ + viewer: viewerFromCtx(ctx), + user: ctx.get('user'), + channelId: createChannelID(ctx.req.valid('param').channel_id), + }); + return ctx.body(null, 204); + }, + ); + app.delete( + '/channels/:channel_id/thread-members/@me', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_DELETE), + LoginRequired, + Validator('param', ChannelIdParam), + Validator('query', ThreadLocationQuery), + OpenAPI({ + operationId: 'leave_thread', + summary: 'Leave a thread', + description: 'Removes the current user from the thread. The thread must not be archived.', + responseSchema: null, + statusCode: 204, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + await ctx.get('threadService').members.leave({ + viewer: viewerFromCtx(ctx), + user: ctx.get('user'), + channelId: createChannelID(ctx.req.valid('param').channel_id), + }); + return ctx.body(null, 204); + }, + ); + app.get( + '/channels/:channel_id/thread-members/:user_id', + ChannelThreadsRouteGuard({botOnly: true}), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_GET), + LoginRequired, + BotOnly, + Validator('param', ChannelIdUserIdParam), + Validator('query', ThreadMemberGetQuery), + OpenAPI({ + operationId: 'get_thread_member', + summary: 'Get a thread member', + description: 'Returns the thread member object of the user when the user is a member of the thread.', + responseSchema: ThreadMemberResponse, + statusCode: 200, + security: ['botToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const {channel_id, user_id} = ctx.req.valid('param'); + return ctx.json( + await ctx.get('threadService').members.get({ + viewer: viewerFromCtx(ctx), + user: ctx.get('user'), + channelId: createChannelID(channel_id), + targetId: createUserID(user_id), + withMember: ctx.req.valid('query').with_member, + requestCache: ctx.get('requestCache'), + }), + ); + }, + ); + app.put( + '/channels/:channel_id/thread-members/:user_id', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_PUT), + LoginRequired, + SelfThreadMemberAlias('join'), + Validator('param', ChannelIdUserIdParam), + Validator('query', ThreadLocationQuery), + OpenAPI({ + operationId: 'add_thread_member', + summary: 'Add a thread member', + description: + 'Adds another guild member to the thread. Requires permission to send messages in threads, and the thread must not be archived.', + responseSchema: null, + statusCode: 204, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const {channel_id, user_id} = ctx.req.valid('param'); + await ctx.get('threadService').members.add({ + viewer: viewerFromCtx(ctx), + user: ctx.get('user'), + channelId: createChannelID(channel_id), + targetId: createUserID(user_id), + }); + return ctx.body(null, 204); + }, + ); + app.delete( + '/channels/:channel_id/thread-members/:user_id', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_DELETE), + LoginRequired, + SelfThreadMemberAlias('leave'), + Validator('param', ChannelIdUserIdParam), + Validator('query', ThreadLocationQuery), + OpenAPI({ + operationId: 'remove_thread_member', + summary: 'Remove a thread member', + description: + 'Removes a member from the thread. Requires the manage threads permission, or being the creator of a private thread. The thread must not be archived.', + responseSchema: null, + statusCode: 204, + security: ['botToken', 'sessionToken'], + tags: TAGS, + experiment: EXPERIMENT, + }), + async (ctx) => { + const {channel_id, user_id} = ctx.req.valid('param'); + await ctx.get('threadService').members.remove({ + viewer: viewerFromCtx(ctx), + user: ctx.get('user'), + channelId: createChannelID(channel_id), + targetId: createUserID(user_id), + }); + return ctx.body(null, 204); + }, + ); +} diff --git a/fluxer_api/src/api/channel/controllers/ThreadMemberSettingsController.ts b/fluxer_api/src/api/channel/controllers/ThreadMemberSettingsController.ts new file mode 100644 index 000000000..2bd1e9534 --- /dev/null +++ b/fluxer_api/src/api/channel/controllers/ThreadMemberSettingsController.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createChannelID} from '@app/api/BrandedTypes'; +import {ChannelThreadsRouteGuard} from '@app/api/channel/threads/ChannelThreadsRouteGuard'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; +import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; +import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; +import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; +import {RateLimitConfigs} from '@app/api/RateLimitConfig'; +import type {HonoApp} from '@app/api/types/HonoEnv'; +import {Validator} from '@app/api/Validator'; +import {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; +import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import {ThreadMemberSettingsRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas'; + +export function ThreadMemberSettingsController(app: HonoApp) { + app.patch( + '/channels/:channel_id/thread-members/@me/settings', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_SETTINGS), + LoginRequired, + DefaultUserOnly, + Validator('param', ChannelIdParam), + Validator('json', ThreadMemberSettingsRequest), + OpenAPI({ + operationId: 'update_thread_member_settings', + summary: 'Update thread settings', + description: + "Updates the current user's notification settings for a thread they are a member of. Returns the thread member, or 204 when nothing changed.", + responseSchema: ThreadMemberResponse, + statusCode: [200, 204], + bodylessStatusCodes: [204], + security: ['sessionToken'], + tags: 'Channels', + experiment: 'channel_threads', + }), + async (ctx) => { + const member = await ctx.get('threadService').memberSettings.update({ + viewer: viewerFromCtx(ctx), + userId: ctx.get('user').id, + channelId: createChannelID(ctx.req.valid('param').channel_id), + data: ctx.req.valid('json'), + }); + return member ? ctx.json(member, 200) : ctx.body(null, 204); + }, + ); +} diff --git a/fluxer_api/src/api/channel/controllers/index.ts b/fluxer_api/src/api/channel/controllers/index.ts index 6d9b5b755..b3c3b7ccc 100644 --- a/fluxer_api/src/api/channel/controllers/index.ts +++ b/fluxer_api/src/api/channel/controllers/index.ts @@ -3,9 +3,12 @@ import {CallController} from '@app/api/channel/controllers/CallController'; import {ChannelController} from '@app/api/channel/controllers/ChannelController'; import {ChannelFollowController} from '@app/api/channel/controllers/ChannelFollowController'; +import {ForumController} from '@app/api/channel/controllers/ForumController'; import {MessageController} from '@app/api/channel/controllers/MessageController'; import {MessageInteractionController} from '@app/api/channel/controllers/MessageInteractionController'; import {StreamController} from '@app/api/channel/controllers/StreamController'; +import {ThreadController} from '@app/api/channel/controllers/ThreadController'; +import {ThreadMemberSettingsController} from '@app/api/channel/controllers/ThreadMemberSettingsController'; import type {HonoApp} from '@app/api/types/HonoEnv'; export function registerChannelControllers(app: HonoApp) { @@ -15,4 +18,7 @@ export function registerChannelControllers(app: HonoApp) { MessageController(app); CallController(app); StreamController(app); + ThreadController(app); + ForumController(app); + ThreadMemberSettingsController(app); } diff --git a/fluxer_api/src/api/channel/repositories/ChannelDataRepository.ts b/fluxer_api/src/api/channel/repositories/ChannelDataRepository.ts index 094e23d63..c262a6c0e 100644 --- a/fluxer_api/src/api/channel/repositories/ChannelDataRepository.ts +++ b/fluxer_api/src/api/channel/repositories/ChannelDataRepository.ts @@ -1,14 +1,15 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; +import {type ChannelID, channelIdToMessageId, type GuildID, type MessageID, type UserID} from '@app/api/BrandedTypes'; import { privateChannelFanOutTargets, privateChannelLastMessageIdPatch, privateChannelMetadataPatch, } from '@app/api/channel/PrivateChannelSnapshot'; -import {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository'; +import {type GuildChannelListMode, IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository'; import { BatchBuilder, + executeConditional, fetchMany, fetchManyInChunks, fetchOne, @@ -18,10 +19,13 @@ import {Db} from '@app/api/database/CassandraTypes'; import {buildPatchFromData, executeVersionedUpdate} from '@app/api/database/CassandraVersionedUpdate'; import type {ChannelRow} from '@app/api/database/types/ChannelTypes'; import {CHANNEL_COLUMNS} from '@app/api/database/types/ChannelTypes'; +import type {ThreadStatsRow} from '@app/api/database/types/ThreadTypes'; +import {guildActive, isTainted} from '@app/api/experiment/ChannelThreadsGate'; import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import {Channel} from '@app/api/models/Channel'; -import {Channels, ChannelsByGuild, PrivateChannels} from '@app/api/Tables'; +import {Channels, ChannelsByGuild, PrivateChannels, ThreadOnlyChannelsByGuild, ThreadStats} from '@app/api/Tables'; +import {THREAD_CHANNEL_TYPES, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; const FETCH_CHANNEL_BY_ID = Channels.select({ where: [Channels.where.eq('channel_id'), Channels.where.eq('soft_deleted')], @@ -33,6 +37,11 @@ const FETCH_CHANNELS_BY_IDS = Channels.select({ const FETCH_GUILD_CHANNELS_BY_GUILD_ID = ChannelsByGuild.select({ where: ChannelsByGuild.where.eq('guild_id'), }); +const FETCH_THREAD_ONLY_CHANNELS_BY_GUILD_ID = ThreadOnlyChannelsByGuild.select({ + where: ThreadOnlyChannelsByGuild.where.eq('guild_id'), +}); +const THREAD_STATS_CAS_ATTEMPTS = 16; +const FETCH_THREAD_STATS = ThreadStats.select({where: ThreadStats.where.eq('thread_id'), limit: 1}); const FETCH_OPEN_PRIVATE_CHANNEL_TARGET = PrivateChannels.selectCql({ columns: ['user_id'], where: [PrivateChannels.where.eq('user_id'), PrivateChannels.where.eq('channel_id')], @@ -70,12 +79,14 @@ export class ChannelDataRepository extends IChannelDataRepository { Channels, {initialData: oldData}, ); - if (data.guild_id) { + if (data.guild_id && !THREAD_CHANNEL_TYPES.has(data.type)) { await upsertOne( - ChannelsByGuild.upsertAll({ - guild_id: data.guild_id, - channel_id: channelId, - }), + THREAD_ONLY_CHANNEL_TYPES.has(data.type) + ? ThreadOnlyChannelsByGuild.upsertAll({guild_id: data.guild_id, channel_id: channelId}) + : ChannelsByGuild.upsertAll({ + guild_id: data.guild_id, + channel_id: channelId, + }), ); } const finalRow: ChannelRow = {...data, version: result.finalVersion ?? 0}; @@ -83,7 +94,7 @@ export class ChannelDataRepository extends IChannelDataRepository { return new Channel(finalRow); } - async updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise { + async updateLastMessageId(channelId: ChannelID, messageId: MessageID, opts?: {isInsert?: boolean}): Promise { this.requestCache?.channels.delete(channelId); const existing = await fetchOne( FETCH_CHANNEL_BY_ID.bind({ @@ -92,6 +103,9 @@ export class ChannelDataRepository extends IChannelDataRepository { }), ); if (!existing) return; + if (opts?.isInsert && THREAD_CHANNEL_TYPES.has(existing.type) && messageId !== channelIdToMessageId(channelId)) { + await this.adjustThreadStats(channelId, 1, 1); + } const prev = existing.last_message_id ?? null; if (prev !== null && messageId <= prev) return; await upsertOne( @@ -100,6 +114,34 @@ export class ChannelDataRepository extends IChannelDataRepository { void this.fanOutPrivateChannelLastMessageId(existing, messageId); } + async adjustThreadStats(threadId: ChannelID, messageDelta: number, sentDelta: number): Promise { + for (let attempt = 0; attempt < THREAD_STATS_CAS_ATTEMPTS; attempt++) { + const stats = await fetchOne(FETCH_THREAD_STATS.bind({thread_id: threadId})); + const messageCount = Math.max(0, (stats?.message_count ?? 0) + messageDelta); + const totalMessageSent = Math.max(0, (stats?.total_message_sent ?? 0) + sentDelta); + const applied = await executeConditional( + stats + ? ThreadStats.conditionalPatchByPk( + {thread_id: threadId}, + {message_count: Db.set(messageCount), total_message_sent: Db.set(totalMessageSent)}, + {message_count: stats.message_count ?? null, total_message_sent: stats.total_message_sent ?? null}, + ) + : ThreadStats.insertIfNotExists({ + thread_id: threadId, + message_count: messageCount, + total_message_sent: totalMessageSent, + }), + ); + if (applied) return; + } + Logger.warn({threadId: threadId.toString()}, 'Gave up adjusting thread stats under contention'); + } + + async patchIndexedAt(channelId: ChannelID, indexedAt: Date): Promise { + this.requestCache?.channels.delete(channelId); + await upsertOne(Channels.patchByPk({channel_id: channelId, soft_deleted: false}, {indexed_at: Db.set(indexedAt)})); + } + private async writeThroughPrivateChannelMetadata(row: ChannelRow): Promise { try { const targets = await this.listOpenPrivateChannelTargets(row); @@ -175,7 +217,7 @@ export class ChannelDataRepository extends IChannelDataRepository { ); } - async delete(channelId: ChannelID, guildId?: GuildID): Promise { + async delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise { this.requestCache?.channels.delete(channelId); const batch = new BatchBuilder(); batch.addPrepared( @@ -184,23 +226,30 @@ export class ChannelDataRepository extends IChannelDataRepository { soft_deleted: false, }), ); - if (guildId) { + if (guildId && (type === undefined || !THREAD_CHANNEL_TYPES.has(type))) { batch.addPrepared( - ChannelsByGuild.deleteByPk({ - guild_id: guildId, - channel_id: channelId, - }), + type !== undefined && THREAD_ONLY_CHANNEL_TYPES.has(type) + ? ThreadOnlyChannelsByGuild.deleteByPk({guild_id: guildId, channel_id: channelId}) + : ChannelsByGuild.deleteByPk({ + guild_id: guildId, + channel_id: channelId, + }), ); } await batch.execute(); } - async listGuildChannels(guildId: GuildID): Promise> { - const guildChannels = await fetchMany<{ - channel_id: bigint; - }>(FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId})); - if (guildChannels.length === 0) return []; - const channelIds = guildChannels.map((c) => c.channel_id); + async listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise> { + const includeThreadOnly = + mode === 'enrolled' ? guildActive(guildId) : await isTainted(guildId, {fresh: mode === 'complete'}); + const [guildChannels, threadOnlyChannels] = await Promise.all([ + fetchMany<{channel_id: bigint}>(FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId})), + includeThreadOnly + ? fetchMany<{channel_id: bigint}>(FETCH_THREAD_ONLY_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId})) + : Promise.resolve([]), + ]); + if (guildChannels.length === 0 && threadOnlyChannels.length === 0) return []; + const channelIds = [...guildChannels, ...threadOnlyChannels].map((c) => c.channel_id); const channels = await fetchManyInChunks(FETCH_CHANNELS_BY_IDS, channelIds, (chunk) => ({ channel_ids: chunk, soft_deleted: false, @@ -218,9 +267,12 @@ export class ChannelDataRepository extends IChannelDataRepository { } async countGuildChannels(guildId: GuildID): Promise { - const guildChannels = await fetchMany<{ - channel_id: bigint; - }>(FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId})); - return guildChannels.length; + const [guildChannels, threadOnlyChannels] = await Promise.all([ + fetchMany<{channel_id: bigint}>(FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId})), + guildActive(guildId) + ? fetchMany<{channel_id: bigint}>(FETCH_THREAD_ONLY_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId})) + : Promise.resolve([]), + ]); + return guildChannels.length + threadOnlyChannels.length; } } diff --git a/fluxer_api/src/api/channel/repositories/ChannelRepository.ts b/fluxer_api/src/api/channel/repositories/ChannelRepository.ts index 2336a55f4..17601eda5 100644 --- a/fluxer_api/src/api/channel/repositories/ChannelRepository.ts +++ b/fluxer_api/src/api/channel/repositories/ChannelRepository.ts @@ -5,12 +5,15 @@ import {CrosspostedMessageRepository} from '@app/api/channel/repositories/Crossp import {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; import {MessageInteractionRepository} from '@app/api/channel/repositories/MessageInteractionRepository'; import {MessageRepository} from '@app/api/channel/repositories/MessageRepository'; +import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository'; +import {enqueueRepairThreadIndexes} from '@app/api/channel/threads/ThreadJobs'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; export class ChannelRepository extends IChannelRepositoryAggregate { readonly channelData: ChannelDataRepository; readonly messages: MessageRepository; readonly messageInteractions: MessageInteractionRepository; + readonly threads: ThreadRepository; readonly crossposts: CrosspostedMessageRepository; constructor(requestCache?: RequestCache) { @@ -18,6 +21,7 @@ export class ChannelRepository extends IChannelRepositoryAggregate { this.channelData = new ChannelDataRepository(requestCache); this.messages = new MessageRepository(this.channelData); this.messageInteractions = new MessageInteractionRepository(this.messages); + this.threads = new ThreadRepository(this.channelData, this.messages, enqueueRepairThreadIndexes); this.crossposts = new CrosspostedMessageRepository(); } } diff --git a/fluxer_api/src/api/channel/repositories/IChannelDataRepository.ts b/fluxer_api/src/api/channel/repositories/IChannelDataRepository.ts index a40827603..4086cb618 100644 --- a/fluxer_api/src/api/channel/repositories/IChannelDataRepository.ts +++ b/fluxer_api/src/api/channel/repositories/IChannelDataRepository.ts @@ -4,18 +4,22 @@ import type {ChannelID, GuildID, MessageID} from '@app/api/BrandedTypes'; import type {ChannelRow} from '@app/api/database/types/ChannelTypes'; import type {Channel} from '@app/api/models/Channel'; +export type GuildChannelListMode = 'enrolled' | 'maintenance' | 'complete'; + export abstract class IChannelDataRepository { abstract findUnique(channelId: ChannelID): Promise; - abstract upsert(data: ChannelRow): Promise; + abstract upsert(data: ChannelRow, oldData?: ChannelRow | null): Promise; - abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise; + abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID, opts?: {isInsert?: boolean}): Promise; - abstract delete(channelId: ChannelID, guildId?: GuildID): Promise; + abstract delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise; - abstract listGuildChannels(guildId: GuildID): Promise>; + abstract listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise>; abstract listChannels(channelIds: Array): Promise>; abstract countGuildChannels(guildId: GuildID): Promise; + + abstract patchIndexedAt(channelId: ChannelID, indexedAt: Date): Promise; } diff --git a/fluxer_api/src/api/channel/repositories/IChannelRepositoryAggregate.ts b/fluxer_api/src/api/channel/repositories/IChannelRepositoryAggregate.ts index 28d3494ad..2bba1900e 100644 --- a/fluxer_api/src/api/channel/repositories/IChannelRepositoryAggregate.ts +++ b/fluxer_api/src/api/channel/repositories/IChannelRepositoryAggregate.ts @@ -4,10 +4,12 @@ import type {IChannelDataRepository} from '@app/api/channel/repositories/IChanne import type {ICrosspostedMessageRepository} from '@app/api/channel/repositories/ICrosspostedMessageRepository'; import type {IMessageInteractionRepository} from '@app/api/channel/repositories/IMessageInteractionRepository'; import type {IMessageRepository} from '@app/api/channel/repositories/IMessageRepository'; +import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository'; export abstract class IChannelRepositoryAggregate { abstract readonly channelData: IChannelDataRepository; abstract readonly messages: IMessageRepository; abstract readonly messageInteractions: IMessageInteractionRepository; + abstract readonly threads: IThreadRepository; abstract readonly crossposts: ICrosspostedMessageRepository; } diff --git a/fluxer_api/src/api/channel/repositories/IMessageRepository.ts b/fluxer_api/src/api/channel/repositories/IMessageRepository.ts index 7fafcbb66..12f52ba96 100644 --- a/fluxer_api/src/api/channel/repositories/IMessageRepository.ts +++ b/fluxer_api/src/api/channel/repositories/IMessageRepository.ts @@ -9,6 +9,11 @@ export interface ListMessagesOptions { immediateAfter?: boolean; } +export interface UpsertMessageOptions { + isInsert?: boolean; + skipParentLastMessageId?: boolean; +} + export abstract class IMessageRepository { abstract listMessages( channelId: ChannelID, @@ -20,7 +25,7 @@ export abstract class IMessageRepository { abstract getMessage(channelId: ChannelID, messageId: MessageID): Promise; - abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise; + abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise; abstract updateEmbeds(message: Message): Promise; diff --git a/fluxer_api/src/api/channel/repositories/IThreadRepository.ts b/fluxer_api/src/api/channel/repositories/IThreadRepository.ts new file mode 100644 index 000000000..d373073ff --- /dev/null +++ b/fluxer_api/src/api/channel/repositories/IThreadRepository.ts @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; +import type {ChannelRow} from '@app/api/database/types/ChannelTypes'; +import type {GuildThreadStateRow, ThreadParentConfigRow, ThreadStateRow} from '@app/api/database/types/ThreadTypes'; +import type {MuteConfig} from '@app/api/database/types/UserTypes'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; +import type {ThreadParentConfig} from '@app/api/models/ThreadParentConfig'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import type {ThreadStats} from '@app/api/models/ThreadStats'; + +export interface CreateThreadMember { + userId: UserID; + flags: number; +} + +export interface CreateThreadParams { + channel: ChannelRow; + parentType: number; + autoArchiveDuration: number; + invitable: boolean | null; + flags: number; + appliedTags: Array; + hasStarter: boolean; + createdAt: Date; + members: Array; +} + +export type ThreadStatePatch = Partial< + Pick< + ThreadStateRow, + 'archived' | 'locked' | 'invitable' | 'auto_archive_duration' | 'archive_timestamp' | 'flags' | 'applied_tags' + > +>; + +export interface ThreadStateTransition { + previous: ThreadState; + state: ThreadState; +} + +export interface ArchivedThreadPage { + threads: Array; + hasMore: boolean; +} + +export interface ThreadMemberSettingsPatch { + flags?: number; + muted?: boolean; + muteConfig?: MuteConfig | null; +} + +export interface ThreadMemberAddResult { + added: Array; + state: ThreadState; +} + +export interface ThreadMemberRemoveResult { + removed: Array; + state: ThreadState | null; +} + +export type ThreadParentConfigPatch = Partial>; + +export abstract class IThreadRepository { + abstract getState(threadId: ChannelID): Promise; + + abstract getStates(threadIds: Array): Promise>; + + abstract getStats(threadId: ChannelID): Promise; + + abstract getStatsMany(threadIds: Array): Promise>; + + abstract adjustMessageCount(threadId: ChannelID, delta: number): Promise; + + abstract create(params: CreateThreadParams): Promise; + + abstract updateState( + threadId: ChannelID, + mutate: (current: ThreadState) => ThreadStatePatch | null, + ): Promise; + + abstract claimForumPin(parentId: ChannelID, threadId: ChannelID): Promise; + + abstract releaseForumPin(parentId: ChannelID, threadId: ChannelID): Promise; + + abstract getForumPin(parentId: ChannelID): Promise; + + abstract countActiveThreads(guildId: GuildID): Promise; + + abstract listActiveThreads(guildId: GuildID): Promise>; + + abstract listArchivedThreads( + parentId: ChannelID, + isPrivate: boolean, + opts: {before?: Date; limit: number}, + ): Promise; + + abstract listJoinedPrivateArchivedThreads( + userId: UserID, + guildId: GuildID, + parentId: ChannelID, + opts: {before?: ChannelID; limit: number}, + ): Promise; + + abstract listJoinedThreadIds(userId: UserID, guildId: GuildID): Promise>; + + abstract listJoinedPrivateThreadIds( + userId: UserID, + guildId: GuildID, + parentId: ChannelID, + limit: number, + ): Promise>; + + abstract repairThreadIndexes(threadIds: Array): Promise; + + abstract getMember(threadId: ChannelID, userId: UserID): Promise; + + abstract getMembers(threadId: ChannelID, userIds: Array): Promise>; + + abstract listMembers(threadId: ChannelID, opts: {after?: UserID; limit: number}): Promise>; + + abstract addMembers( + threadId: ChannelID, + members: Array, + opts?: {joinTimestamp?: Date}, + ): Promise; + + abstract removeMembers(threadId: ChannelID, userIds: Array): Promise; + + abstract updateMemberSettings(expected: ThreadMember, patch: ThreadMemberSettingsPatch): Promise; + + abstract listThreadIdsByParent( + parentId: ChannelID, + opts: {after?: ChannelID; limit: number}, + ): Promise>; + + abstract listParentThreads(parentId: ChannelID): Promise>; + + abstract setThreadType(threadId: ChannelID, type: number): Promise; + + abstract listGuildThreadIds( + guildId: GuildID, + opts?: {activeSince?: Date; parents?: ReadonlyArray<{id: ChannelID; type: number}>}, + ): Promise>; + + abstract purgeThread(threadId: ChannelID): Promise; + + abstract revertParentLastMessageId( + parentId: ChannelID, + threadId: ChannelID, + previous: MessageID | null, + ): Promise; + + abstract purgeGuild(guildId: GuildID): Promise; + + abstract getGuildMarker(guildId: GuildID): Promise; + + abstract ensureGuildMarker(guildId: GuildID, opts?: {permsSeededAt?: Date}): Promise; + + abstract markGuildPermsSeeded(guildId: GuildID, at: Date): Promise; + + abstract markGuildSearchBackfilled(guildId: GuildID, at: Date): Promise; + abstract clearGuildSearchBackfilled(guildId: GuildID): Promise; + + abstract getParentConfig(guildId: GuildID, channelId: ChannelID): Promise; + + abstract listParentConfigs(guildId: GuildID): Promise>; + + abstract patchParentConfig(guildId: GuildID, channelId: ChannelID, patch: ThreadParentConfigPatch): Promise; + + abstract deleteParentConfig(guildId: GuildID, channelId: ChannelID): Promise; +} diff --git a/fluxer_api/src/api/channel/repositories/MessageRepository.ts b/fluxer_api/src/api/channel/repositories/MessageRepository.ts index 558a76dca..5794f5aee 100644 --- a/fluxer_api/src/api/channel/repositories/MessageRepository.ts +++ b/fluxer_api/src/api/channel/repositories/MessageRepository.ts @@ -2,7 +2,11 @@ import type {AttachmentID, ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; import type {ChannelDataRepository} from '@app/api/channel/repositories/ChannelDataRepository'; -import {IMessageRepository, type ListMessagesOptions} from '@app/api/channel/repositories/IMessageRepository'; +import { + IMessageRepository, + type ListMessagesOptions, + type UpsertMessageOptions, +} from '@app/api/channel/repositories/IMessageRepository'; import {MessageAttachmentRepository} from '@app/api/channel/repositories/message/MessageAttachmentRepository'; import {MessageAuthorRepository} from '@app/api/channel/repositories/message/MessageAuthorRepository'; import {MessageDataRepository} from '@app/api/channel/repositories/message/MessageDataRepository'; @@ -40,10 +44,14 @@ export class MessageRepository extends IMessageRepository { return this.dataRepo.getMessage(channelId, messageId); } - async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise { + async upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise { const message = await this.dataRepo.upsertMessage(data, oldData); - if (!oldData) { - await this.channelDataRepo.updateLastMessageId(data.channel_id, data.message_id); + if (!oldData && !opts?.skipParentLastMessageId) { + await this.channelDataRepo.updateLastMessageId( + data.channel_id, + data.message_id, + opts?.isInsert ? {isInsert: true} : undefined, + ); } return message; } diff --git a/fluxer_api/src/api/channel/repositories/ThreadRepository.test.ts b/fluxer_api/src/api/channel/repositories/ThreadRepository.test.ts new file mode 100644 index 000000000..2835c77d6 --- /dev/null +++ b/fluxer_api/src/api/channel/repositories/ThreadRepository.test.ts @@ -0,0 +1,992 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {spawnSync} from 'node:child_process'; +import {readFileSync} from 'node:fs'; +import {createServer} from 'node:net'; +import {fileURLToPath} from 'node:url'; +import { + type ChannelID, + channelIdToMessageId, + createChannelID, + createGuildID, + createMessageID, + createUserID, + type GuildID, +} from '@app/api/BrandedTypes'; +import {ChannelRepository} from '@app/api/channel/repositories/ChannelRepository'; +import type {CreateThreadParams} from '@app/api/channel/repositories/IThreadRepository'; +import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository'; +import { + type CassandraQueryExecutorForTesting, + deleteOneOrMany, + executeConditional, + fetchMany, + fetchOne, + setCassandraQueryExecutorForTesting, + upsertOne, +} from '@app/api/database/CassandraQueryExecution'; +import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes'; +import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor'; +import {CHANNEL_COLUMNS, type ChannelRow} from '@app/api/database/types/ChannelTypes'; +import {MESSAGE_COLUMNS} from '@app/api/database/types/MessageTypes'; +import { + clearChannelThreadsTaintCacheForTesting, + syncChannelThreadsConfig, +} from '@app/api/experiment/ChannelThreadsGate'; +import { + ActiveThreadsByGuild, + ArchivedThreadsByParent, + Channels, + ChannelsByGuild, + ForumPinnedThread, + GuildThreadState, + ThreadMembers, + ThreadMembersByUser, + ThreadOnlyChannelsByGuild, + ThreadParentConfig, + ThreadState, + ThreadStats, + ThreadsByParent, +} from '@app/api/Tables'; +import {startDockerContainer} from '@app/api/test/DockerTestContainer'; +import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ChannelFlags, MAX_THREAD_MEMBERS} from '@fluxer/constants/src/ThreadConstants'; +import {MaxThreadMembersError} from '@fluxer/errors/src/domains/channel/MaxThreadMembersError'; +import {ThreadAlreadyCreatedForMessageError} from '@fluxer/errors/src/domains/channel/ThreadAlreadyCreatedForMessageError'; +import { + type ChannelThreadsConfig, + ChannelThreadsConfigSchema, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {createSnowflakeFromTimestamp} from '@fluxer/snowflake/src/Snowflake'; +import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +const GUILD_ID = createGuildID(1_900_000_000_000_000_000n); +const PARENT_ID = createChannelID(1_900_000_000_000_000_100n); +const FORUM_ID = createChannelID(1_900_000_000_000_000_200n); +const OWNER_ID = createUserID(1_900_000_000_000_001_000n); + +class RecordingExecutor implements CassandraQueryExecutorForTesting { + readonly statements: Array = []; + + constructor(private readonly inner: CassandraQueryExecutorForTesting) {} + + async executeQuery, P extends CassandraParams = CassandraParams>( + query: PreparedQuery

, + ): Promise> { + const meta = query.kvMeta; + this.record(meta, meta?.conditions || meta?.ifNotExists || meta?.batchEntries ? 'cas' : undefined); + return this.inner.executeQuery(query); + } + + async executeBatch( + queries: Array<{query: string; params: object; meta?: KvQueryMeta}>, + atomic?: boolean, + ): Promise { + for (const entry of queries) this.record(entry.meta); + await this.inner.executeBatch(queries, atomic); + } + + count(statement: string): number { + return this.statements.filter((entry) => entry === statement).length; + } + + private record(meta: KvQueryMeta | undefined, prefix?: string): void { + if (!meta) return; + this.statements.push(`${prefix ?? meta.action}:${meta.table.name}`); + } +} + +function parseConfig(raw: string | null): ChannelThreadsConfig { + return ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}); +} + +function setThreadsConfig(patch: Partial | null): void { + clearChannelThreadsTaintCacheForTesting(); + syncChannelThreadsConfig(patch === null ? null : JSON.stringify(patch), parseConfig); +} + +function channelRow(channelId: ChannelID, type: number, overrides: Partial = {}): ChannelRow { + return { + channel_id: channelId, + guild_id: GUILD_ID, + type, + name: `channel-${channelId}`, + topic: null, + icon_hash: null, + url: null, + parent_id: null, + position: 0, + owner_id: null, + recipient_ids: null, + nsfw: false, + content_warning_level: null, + content_warning_text: null, + rate_limit_per_user: 0, + bitrate: null, + user_limit: null, + voice_connection_limit: null, + rtc_region: null, + last_message_id: null, + last_pin_timestamp: null, + permission_overwrites: null, + nicks: null, + soft_deleted: false, + indexed_at: null, + version: 1, + ...overrides, + }; +} + +let lastId = 0n; +function freshThreadId(offsetMs = 0): ChannelID { + const base = createSnowflakeFromTimestamp(Date.now() + offsetMs); + if (offsetMs !== 0) return createChannelID(base); + lastId = base > lastId ? base : lastId + 1n; + return createChannelID(lastId); +} + +function createParams(threadId: ChannelID, overrides: Partial = {}): CreateThreadParams { + const type = overrides.channel?.type ?? ChannelTypes.PUBLIC_THREAD; + return { + channel: channelRow(threadId, type, {parent_id: PARENT_ID, owner_id: OWNER_ID, indexed_at: new Date()}), + parentType: ChannelTypes.GUILD_TEXT, + autoArchiveDuration: 4320, + invitable: null, + flags: 0, + appliedTags: [], + hasStarter: false, + createdAt: new Date(), + members: [{userId: OWNER_ID, flags: 1}], + ...overrides, + }; +} + +function describeThreadRepository(backend: string, makeExecutor: () => Promise) { + describe(`ThreadRepository (${backend})`, () => { + let executor: RecordingExecutor; + let repositories: ChannelRepository; + + beforeEach(async () => { + executor = new RecordingExecutor(await makeExecutor()); + setCassandraQueryExecutorForTesting(executor); + setThreadsConfig(null); + repositories = new ChannelRepository(); + await upsertOne(Channels.upsertAll(channelRow(PARENT_ID, ChannelTypes.GUILD_TEXT))); + await upsertOne(ChannelsByGuild.upsertAll({guild_id: GUILD_ID, channel_id: PARENT_ID})); + executor.statements.length = 0; + }); + + afterEach(() => { + setThreadsConfig(null); + }); + + async function taint(guildId: GuildID = GUILD_ID): Promise { + setThreadsConfig({enabled: true, ever_enabled: true, enabled_guild_ids: [guildId.toString()]}); + await upsertOne( + GuildThreadState.upsertAll({ + guild_id: guildId, + first_active_at: new Date(), + perms_seeded_at: null, + search_backfilled_at: null, + }), + ); + } + + it('writes a thread into its side tables and never into the guild channel index', async () => { + const threadId = freshThreadId(); + const state = await repositories.threads.create(createParams(threadId)); + expect(state.stateVersion).toBe(1); + expect(state.memberCount).toBe(1); + expect(state.memberIdsPreview).toEqual([OWNER_ID]); + const channel = await repositories.channelData.findUnique(threadId); + expect(channel?.isThread()).toBe(true); + expect(await repositories.channelData.listGuildChannels(GUILD_ID, 'enrolled')).toHaveLength(1); + expect( + await fetchOne( + ChannelsByGuild.selectCql({ + where: [ChannelsByGuild.where.eq('guild_id'), ChannelsByGuild.where.eq('channel_id')], + }), + {guild_id: GUILD_ID, channel_id: threadId}, + ), + ).toBeNull(); + expect((await repositories.threads.listActiveThreads(GUILD_ID)).map((t) => t.threadId)).toEqual([threadId]); + expect(await repositories.threads.listThreadIdsByParent(PARENT_ID, {limit: 10})).toEqual([threadId]); + expect((await repositories.threads.getParentConfig(GUILD_ID, PARENT_ID))?.hasThreads).toBe(true); + expect((await repositories.threads.getMember(threadId, OWNER_ID))?.flags).toBe(1); + }); + + it('lets exactly one of two concurrent creates win', async () => { + const threadId = freshThreadId(); + const results = await Promise.allSettled([ + repositories.threads.create(createParams(threadId)), + repositories.threads.create(createParams(threadId)), + ]); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + const rejected = results.find((result) => result.status === 'rejected') as PromiseRejectedResult; + expect(rejected.reason).toBeInstanceOf(ThreadAlreadyCreatedForMessageError); + }); + + it('refuses a create over a fresh dangling state and repairs one older than 30 seconds', async () => { + const threadId = freshThreadId(); + const dangling = { + thread_id: threadId, + guild_id: GUILD_ID, + parent_id: PARENT_ID, + type: ChannelTypes.PUBLIC_THREAD, + archived: false, + locked: false, + invitable: null, + auto_archive_duration: 4320, + archive_timestamp: new Date(), + created_at: new Date(), + flags: 0, + applied_tags: null, + member_count: 0, + member_ids_preview: null, + has_starter: false, + state_version: 1, + }; + expect(await executeConditional(ThreadState.insertIfNotExists(dangling))).toBe(true); + await expect(repositories.threads.create(createParams(threadId))).rejects.toBeInstanceOf( + ThreadAlreadyCreatedForMessageError, + ); + const staleId = freshThreadId(); + expect( + await executeConditional( + ThreadState.insertIfNotExists({...dangling, thread_id: staleId, created_at: new Date(Date.now() - 31_000)}), + ), + ).toBe(true); + const repaired = await repositories.threads.create(createParams(staleId)); + expect(repaired.memberCount).toBe(1); + expect(await repositories.channelData.findUnique(staleId)).not.toBeNull(); + }); + + it('clears the crashed creator memberships when repairing a dangling create', async () => { + const threadId = freshThreadId(); + const crashed = createUserID(1_900_000_000_000_003_000n); + const createdAt = new Date(Date.now() - 31_000); + expect( + await executeConditional( + ThreadState.insertIfNotExists({ + thread_id: threadId, + guild_id: GUILD_ID, + parent_id: PARENT_ID, + type: ChannelTypes.PRIVATE_THREAD, + archived: false, + locked: false, + invitable: true, + auto_archive_duration: 4320, + archive_timestamp: createdAt, + created_at: createdAt, + flags: 0, + applied_tags: null, + member_count: 1, + member_ids_preview: [crashed], + has_starter: false, + state_version: 1, + }), + ), + ).toBe(true); + const memberRow = { + thread_id: threadId, + guild_id: GUILD_ID, + parent_id: PARENT_ID, + join_timestamp: createdAt, + flags: 1, + muted: false, + mute_config: null, + }; + await upsertOne(ThreadMembers.upsertAll({...memberRow, user_id: crashed})); + await upsertOne(ThreadMembers.upsertAll({...memberRow, user_id: OWNER_ID})); + await upsertOne( + ThreadMembersByUser.upsertAll({ + user_id: crashed, + guild_id: GUILD_ID, + parent_id: PARENT_ID, + is_private: true, + thread_id: threadId, + }), + ); + const repaired = await repositories.threads.create(createParams(threadId)); + expect(repaired.memberCount).toBe(1); + const members = await repositories.threads.listMembers(threadId, {limit: 10}); + expect(members.map((member) => member.userId)).toEqual([OWNER_ID]); + expect(members[0]!.joinTimestamp.getTime()).not.toBe(createdAt.getTime()); + expect(await repositories.threads.listJoinedThreadIds(crashed, GUILD_ID)).toEqual([]); + }); + + it('removes a private membership index row when the thread state is gone', async () => { + const threadId = freshThreadId(); + const params = createParams(threadId); + params.channel.type = ChannelTypes.PRIVATE_THREAD; + await repositories.threads.create(params); + await deleteOneOrMany(ThreadState.deleteByPk({thread_id: threadId})); + const result = await repositories.threads.removeMembers(threadId, [OWNER_ID]); + expect(result.removed.map((member) => member.userId)).toEqual([OWNER_ID]); + expect(await repositories.threads.listJoinedThreadIds(OWNER_ID, GUILD_ID)).toEqual([]); + }); + + it('ignores a dangling threads_by_parent row in lists but enumerates it for maintenance', async () => { + await taint(); + const threadId = freshThreadId(); + await upsertOne( + ThreadsByParent.upsertAll({ + parent_id: PARENT_ID, + thread_id: threadId, + guild_id: GUILD_ID, + type: ChannelTypes.PUBLIC_THREAD, + }), + ); + expect(await repositories.threads.listActiveThreads(GUILD_ID)).toEqual([]); + expect(await repositories.threads.listGuildThreadIds(GUILD_ID)).toEqual([threadId]); + }); + + it('moves index rows on archive and unarchive and clears the pin', async () => { + const threadId = freshThreadId(); + await repositories.threads.create(createParams(threadId, {flags: ChannelFlags.PINNED})); + expect(await repositories.threads.claimForumPin(FORUM_ID, threadId)).toBe(true); + await upsertOne(ThreadState.patchByPk({thread_id: threadId}, {parent_id: {kind: 'set', value: FORUM_ID}})); + const archived = await repositories.threads.updateState(threadId, () => ({archived: true})); + expect(archived?.state.archived).toBe(true); + expect(archived?.state.isPinned).toBe(false); + expect(archived?.state.stateVersion).toBe(2); + expect(await repositories.threads.getForumPin(FORUM_ID)).toBeNull(); + expect(await repositories.threads.listActiveThreads(GUILD_ID)).toEqual([]); + const page = await repositories.threads.listArchivedThreads(FORUM_ID, false, {limit: 10}); + expect(page.threads.map((t) => t.threadId)).toEqual([threadId]); + const unarchived = await repositories.threads.updateState(threadId, () => ({archived: false})); + expect(unarchived?.state.archived).toBe(false); + expect((await repositories.threads.listActiveThreads(GUILD_ID)).map((t) => t.threadId)).toEqual([threadId]); + expect((await repositories.threads.listArchivedThreads(FORUM_ID, false, {limit: 10})).threads).toEqual([]); + }); + + it('serialises racing transitions through the state version', async () => { + const threadId = freshThreadId(); + await repositories.threads.create(createParams(threadId)); + const [archive, lock] = await Promise.all([ + repositories.threads.updateState(threadId, () => ({archived: true})), + repositories.threads.updateState(threadId, () => ({locked: true})), + ]); + expect(archive).not.toBeNull(); + expect(lock).not.toBeNull(); + const state = await repositories.threads.getState(threadId); + expect(state?.archived).toBe(true); + expect(state?.locked).toBe(true); + expect(state?.stateVersion).toBe(3); + expect(await repositories.threads.listActiveThreads(GUILD_ID)).toEqual([]); + expect((await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 10})).threads).toHaveLength(1); + }); + + it('drops and reports stale index rows, and repairs missing ones', async () => { + const drift = vi.fn(); + const threads = new ThreadRepository(repositories.channelData, repositories.messages, drift); + const threadId = freshThreadId(); + await threads.create(createParams(threadId)); + await threads.updateState(threadId, () => ({archived: true})); + await upsertOne( + ActiveThreadsByGuild.upsertAll({ + guild_id: GUILD_ID, + thread_id: threadId, + parent_id: PARENT_ID, + type: ChannelTypes.PUBLIC_THREAD, + }), + ); + expect(await threads.listActiveThreads(GUILD_ID)).toEqual([]); + expect(drift).toHaveBeenCalledWith([threadId]); + expect( + await fetchMany(ActiveThreadsByGuild.selectCql({where: ActiveThreadsByGuild.where.eq('guild_id')}), { + guild_id: GUILD_ID, + }), + ).toEqual([]); + const state = await threads.getState(threadId); + await executeConditional( + ArchivedThreadsByParent.conditionalDeleteByPk( + {parent_id: PARENT_ID, is_private: false, archive_timestamp: state!.archiveTimestamp!, thread_id: threadId}, + {guild_id: GUILD_ID}, + ), + ); + expect((await threads.listArchivedThreads(PARENT_ID, false, {limit: 10})).threads).toEqual([]); + await threads.repairThreadIndexes([threadId]); + expect((await threads.listArchivedThreads(PARENT_ID, false, {limit: 10})).threads).toHaveLength(1); + }); + + it('claims one pinned post per forum and recovers a stale claim', async () => { + const first = freshThreadId(); + const second = freshThreadId(); + await repositories.threads.create(createParams(first, {flags: ChannelFlags.PINNED})); + await repositories.threads.create(createParams(second)); + expect(await repositories.threads.claimForumPin(FORUM_ID, first)).toBe(true); + expect(await repositories.threads.claimForumPin(FORUM_ID, first)).toBe(true); + expect(await repositories.threads.claimForumPin(FORUM_ID, second)).toBe(false); + await upsertOne(ThreadState.patchByPk({thread_id: first}, {flags: {kind: 'set', value: 0}})); + expect(await repositories.threads.claimForumPin(FORUM_ID, second)).toBe(true); + expect(await repositories.threads.getForumPin(FORUM_ID)).toBe(second); + }); + + it('pages archived threads newest first with before cursors and limits 2 and 100', async () => { + const ids: Array = []; + for (let i = 0; i < 5; i++) { + const threadId = freshThreadId(); + ids.push(threadId); + await repositories.threads.create(createParams(threadId)); + await repositories.threads.updateState(threadId, () => ({ + archived: true, + archive_timestamp: new Date(1_700_000_000_000 + i * 1000), + })); + } + const newestFirst = [...ids].reverse(); + const first = await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 2}); + expect(first.threads.map((t) => t.threadId)).toEqual(newestFirst.slice(0, 2)); + expect(first.hasMore).toBe(true); + const second = await repositories.threads.listArchivedThreads(PARENT_ID, false, { + limit: 2, + before: first.threads[1]!.archiveTimestamp!, + }); + expect(second.threads.map((t) => t.threadId)).toEqual(newestFirst.slice(2, 4)); + expect(second.hasMore).toBe(true); + const all = await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 100}); + expect(all.threads.map((t) => t.threadId)).toEqual(newestFirst); + expect(all.hasMore).toBe(false); + expect((await repositories.threads.listArchivedThreads(PARENT_ID, true, {limit: 100})).threads).toEqual([]); + }); + + it('pages past stale rows when several archived threads share one timestamp', async () => { + const tied = new Date(1_700_000_000_000); + const ids: Array = []; + for (let i = 0; i < 4; i++) { + const threadId = freshThreadId(); + ids.push(threadId); + await repositories.threads.create(createParams(threadId)); + await repositories.threads.updateState(threadId, () => ({archived: true, archive_timestamp: tied})); + } + const older = freshThreadId(); + await repositories.threads.create(createParams(older)); + await repositories.threads.updateState(older, () => ({ + archived: true, + archive_timestamp: new Date(tied.getTime() - 1000), + })); + for (const offset of [1n, 2n]) { + await upsertOne( + ArchivedThreadsByParent.upsertAll({ + parent_id: PARENT_ID, + is_private: false, + archive_timestamp: tied, + thread_id: createChannelID(ids[3]! + 1000n * offset), + guild_id: GUILD_ID, + }), + ); + } + const newestFirst = [...ids].reverse(); + const first = await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 2}); + expect(first.threads.map((t) => t.threadId)).toEqual(newestFirst.slice(0, 2)); + expect(first.hasMore).toBe(true); + const all = await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 4}); + expect(all.threads.map((t) => t.threadId)).toEqual(newestFirst); + expect(all.hasMore).toBe(true); + }); + + it('lists only joined private archived threads, newest id first', async () => { + const member = createUserID(1_900_000_000_000_002_000n); + const privateIds: Array = []; + for (let i = 0; i < 3; i++) { + const threadId = freshThreadId(); + privateIds.push(threadId); + const params = createParams(threadId, {members: [{userId: member, flags: 1}]}); + params.channel.type = ChannelTypes.PRIVATE_THREAD; + await repositories.threads.create(params); + await repositories.threads.updateState(threadId, () => ({archived: true})); + } + const publicId = freshThreadId(); + await repositories.threads.create(createParams(publicId, {members: [{userId: member, flags: 1}]})); + await repositories.threads.updateState(publicId, () => ({archived: true})); + executor.statements.length = 0; + const page = await repositories.threads.listJoinedPrivateArchivedThreads(member, GUILD_ID, PARENT_ID, {limit: 2}); + expect(page.threads.map((t) => t.threadId)).toEqual([privateIds[2], privateIds[1]]); + expect(page.hasMore).toBe(true); + const next = await repositories.threads.listJoinedPrivateArchivedThreads(member, GUILD_ID, PARENT_ID, { + limit: 2, + before: privateIds[1], + }); + expect(next.threads.map((t) => t.threadId)).toEqual([privateIds[0]]); + expect(next.hasMore).toBe(false); + expect(page.threads.every((t) => t.isPrivate)).toBe(true); + }); + + it('adds a 250 member batch with one state compare-and-set', async () => { + const threadId = freshThreadId(); + await repositories.threads.create(createParams(threadId)); + executor.statements.length = 0; + const members = Array.from({length: 250}, (_, index) => ({ + userId: createUserID(1_900_000_000_010_000_000n + BigInt(index)), + flags: 0, + })); + const result = await repositories.threads.addMembers(threadId, members); + expect(result?.added).toHaveLength(250); + expect(result?.state.memberCount).toBe(251); + expect(result?.state.memberIdsPreview).toHaveLength(8); + expect(result?.state.memberIdsPreview[0]).toBe(members[249]!.userId); + expect(executor.count('cas:thread_state')).toBe(1); + expect(executor.count('cas:thread_members')).toBe(1); + const again = await repositories.threads.addMembers(threadId, members.slice(0, 3)); + expect(again?.added).toEqual([]); + expect((await repositories.threads.listMembers(threadId, {limit: 1000})).length).toBe(251); + }); + + it('writes creator memberships and the seeded stamp only under compare-and-set', async () => { + const threadId = freshThreadId(); + await repositories.threads.create( + createParams(threadId, { + members: [ + {userId: OWNER_ID, flags: 1}, + {userId: OWNER_ID, flags: 1}, + ], + }), + ); + expect(executor.count('cas:thread_members')).toBe(1); + expect(executor.statements.some((entry) => entry.endsWith(':thread_members') && !entry.startsWith('cas:'))).toBe( + false, + ); + expect((await repositories.threads.listMembers(threadId, {limit: 10})).map((m) => m.userId)).toEqual([OWNER_ID]); + const guildId = createGuildID(1_900_000_000_000_000_777n); + await repositories.threads.ensureGuildMarker(guildId); + expect((await repositories.threads.getGuildMarker(guildId))?.perms_seeded_at ?? null).toBeNull(); + executor.statements.length = 0; + const seededAt = new Date(1_800_000_000_000); + await repositories.threads.markGuildPermsSeeded(guildId, seededAt); + await repositories.threads.markGuildPermsSeeded(guildId, new Date()); + expect((await repositories.threads.getGuildMarker(guildId))?.perms_seeded_at).toEqual(seededAt); + expect( + executor.statements.filter((entry) => entry.endsWith(':guild_thread_state') && !entry.startsWith('select:')), + ).toEqual(['cas:guild_thread_state']); + const fresh = createGuildID(1_900_000_000_000_000_778n); + await repositories.threads.markGuildPermsSeeded(fresh, seededAt); + expect((await repositories.threads.getGuildMarker(fresh))?.perms_seeded_at).toEqual(seededAt); + }); + + it('marks search backfill on a full marker row and clears it for a reindex', async () => { + const guildId = createGuildID(1_900_000_000_000_000_779n); + const at = new Date(1_800_000_000_000); + await repositories.threads.markGuildSearchBackfilled(guildId, at); + const marker = await repositories.threads.getGuildMarker(guildId); + expect(marker?.first_active_at).toBeInstanceOf(Date); + expect(marker?.search_backfilled_at).toEqual(at); + await repositories.threads.clearGuildSearchBackfilled(guildId); + const cleared = await repositories.threads.getGuildMarker(guildId); + expect(cleared?.search_backfilled_at ?? null).toBeNull(); + expect(cleared?.first_active_at).toEqual(marker?.first_active_at); + }); + + it('refuses joins past the member cap', async () => { + const threadId = freshThreadId(); + await repositories.threads.create(createParams(threadId)); + await upsertOne( + ThreadState.patchByPk({thread_id: threadId}, {member_count: {kind: 'set', value: MAX_THREAD_MEMBERS - 1}}), + ); + await expect( + repositories.threads.addMembers(threadId, [ + {userId: createUserID(1_900_000_000_020_000_001n), flags: 0}, + {userId: createUserID(1_900_000_000_020_000_002n), flags: 0}, + ]), + ).rejects.toBeInstanceOf(MaxThreadMembersError); + const single = await repositories.threads.addMembers(threadId, [ + {userId: createUserID(1_900_000_000_020_000_003n), flags: 0}, + ]); + expect(single?.state.memberCount).toBe(MAX_THREAD_MEMBERS); + }); + + it('lets only one of two concurrent joins take the last member slot', async () => { + const threadId = freshThreadId(); + await repositories.threads.create(createParams(threadId)); + await upsertOne( + ThreadState.patchByPk({thread_id: threadId}, {member_count: {kind: 'set', value: MAX_THREAD_MEMBERS - 1}}), + ); + const joiners = [createUserID(1_900_000_000_025_000_001n), createUserID(1_900_000_000_025_000_002n)]; + const results = await Promise.allSettled( + joiners.map((userId) => repositories.threads.addMembers(threadId, [{userId, flags: 0}])), + ); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + const rejected = results.find((result) => result.status === 'rejected') as PromiseRejectedResult; + expect(rejected.reason).toBeInstanceOf(MaxThreadMembersError); + expect((await repositories.threads.getState(threadId))?.memberCount).toBe(MAX_THREAD_MEMBERS); + expect(await repositories.threads.listMembers(threadId, {limit: 100})).toHaveLength(2); + }); + + it('keeps the member count consistent under concurrent adds and removes', async () => { + const threadId = freshThreadId(); + const leaving = createUserID(1_900_000_000_030_000_001n); + await repositories.threads.create( + createParams(threadId, { + members: [ + {userId: OWNER_ID, flags: 1}, + {userId: leaving, flags: 0}, + ], + }), + ); + const joiners = [createUserID(1_900_000_000_030_000_002n), createUserID(1_900_000_000_030_000_003n)]; + await Promise.all([ + repositories.threads.addMembers( + threadId, + joiners.map((userId) => ({userId, flags: 0})), + ), + repositories.threads.removeMembers(threadId, [leaving]), + repositories.threads.removeMembers(threadId, [leaving]), + ]); + const state = await repositories.threads.getState(threadId); + const rows = await repositories.threads.listMembers(threadId, {limit: 100}); + expect(rows.map((row) => row.userId).sort()).toEqual([OWNER_ID, ...joiners].sort()); + expect(state?.memberCount).toBe(3); + expect(state?.memberIdsPreview).not.toContain(leaving); + expect(await repositories.threads.listJoinedThreadIds(leaving, GUILD_ID)).toEqual([]); + }); + + it('rolls back member rows when the member count compare-and-set gives up', async () => { + const threadId = freshThreadId(); + const staying = createUserID(1_900_000_000_040_000_001n); + const joining = createUserID(1_900_000_000_040_000_002n); + await repositories.threads.create( + createParams(threadId, { + members: [ + {userId: OWNER_ID, flags: 1}, + {userId: staying, flags: 0}, + ], + }), + ); + const executeQuery = executor.executeQuery.bind(executor); + const spy = vi.spyOn(executor, 'executeQuery').mockImplementation(async (query) => { + const meta = query.kvMeta; + if (meta?.table.name === 'thread_state' && meta.conditions) { + return [{'[applied]': false}] as never; + } + return executeQuery(query); + }); + await expect(repositories.threads.addMembers(threadId, [{userId: joining, flags: 0}])).rejects.toThrow(); + await expect(repositories.threads.removeMembers(threadId, [staying])).rejects.toThrow(); + spy.mockRestore(); + const rows = await repositories.threads.listMembers(threadId, {limit: 100}); + expect(rows.map((row) => row.userId).sort()).toEqual([OWNER_ID, staying].sort()); + expect((await repositories.threads.getState(threadId))?.memberCount).toBe(2); + const joined = await repositories.threads.addMembers(threadId, [{userId: joining, flags: 0}]); + expect(joined?.added.map((member) => member.userId)).toEqual([joining]); + expect(joined?.state.memberCount).toBe(3); + expect(await repositories.threads.listJoinedThreadIds(joining, GUILD_ID)).toEqual([threadId]); + const left = await repositories.threads.removeMembers(threadId, [staying]); + expect(left.removed.map((member) => member.userId)).toEqual([staying]); + expect(left.state?.memberCount).toBe(2); + }); + + it('counts inserted thread messages only, excludes the starter id and floors at zero', async () => { + const threadId = freshThreadId(); + await repositories.threads.create(createParams(threadId)); + await repositories.channelData.updateLastMessageId(threadId, channelIdToMessageId(threadId), {isInsert: true}); + expect((await repositories.threads.getStats(threadId)).messageCount).toBe(0); + await repositories.channelData.updateLastMessageId(threadId, createMessageID(threadId + 10n)); + expect((await repositories.threads.getStats(threadId)).messageCount).toBe(0); + await repositories.channelData.updateLastMessageId(threadId, createMessageID(threadId + 20n), {isInsert: true}); + await repositories.channelData.updateLastMessageId(threadId, createMessageID(threadId + 30n), {isInsert: true}); + const stats = await repositories.threads.getStats(threadId); + expect(stats.messageCount).toBe(2); + expect(stats.totalMessageSent).toBe(2); + await repositories.threads.adjustMessageCount(threadId, -5); + const floored = await repositories.threads.getStats(threadId); + expect(floored.messageCount).toBe(0); + expect(floored.totalMessageSent).toBe(2); + executor.statements.length = 0; + await repositories.channelData.updateLastMessageId(PARENT_ID, createMessageID(threadId + 40n), {isInsert: true}); + expect(executor.count('select:thread_stats')).toBe(0); + }); + + it('keeps thread message counters exact under concurrent sends and deletes', async () => { + const threadId = freshThreadId(); + await repositories.threads.create(createParams(threadId)); + await Promise.all( + Array.from({length: 8}, (_, index) => + repositories.channelData.updateLastMessageId(threadId, createMessageID(threadId + BigInt(index + 1)), { + isInsert: true, + }), + ), + ); + const sent = await repositories.threads.getStats(threadId); + expect(sent.messageCount).toBe(8); + expect(sent.totalMessageSent).toBe(8); + await Promise.all([ + repositories.threads.adjustMessageCount(threadId, -1), + repositories.threads.adjustMessageCount(threadId, -1), + repositories.channelData.updateLastMessageId(threadId, createMessageID(threadId + 100n), {isInsert: true}), + ]); + const mixed = await repositories.threads.getStats(threadId); + expect(mixed.messageCount).toBe(7); + expect(mixed.totalMessageSent).toBe(9); + }); + + it('enumerates guild threads with and without an activity cutoff', async () => { + await taint(); + const oldThread = freshThreadId(-86_400_000); + const newThread = freshThreadId(); + await repositories.threads.create(createParams(oldThread)); + await repositories.threads.create(createParams(newThread)); + await repositories.channelData.updateLastMessageId( + newThread, + createMessageID(createSnowflakeFromTimestamp(Date.now())), + ); + expect((await repositories.threads.listGuildThreadIds(GUILD_ID)).sort()).toEqual([oldThread, newThread].sort()); + expect( + await repositories.threads.listGuildThreadIds(GUILD_ID, {activeSince: new Date(Date.now() - 3_600_000)}), + ).toEqual([newThread]); + }); + + it('does no thread IO for never-enabled guilds', async () => { + const threadId = freshThreadId(); + await upsertOne( + ThreadsByParent.upsertAll({parent_id: PARENT_ID, thread_id: threadId, guild_id: GUILD_ID, type: 11}), + ); + executor.statements.length = 0; + expect(await repositories.threads.listGuildThreadIds(GUILD_ID)).toEqual([]); + expect(executor.statements).toEqual([]); + await repositories.channelData.listGuildChannels(GUILD_ID, 'maintenance'); + await repositories.channelData.listGuildChannels(GUILD_ID, 'enrolled'); + expect(executor.statements.every((entry) => /:(channels|channels_by_guild_id)$/.test(entry))).toBe(true); + expect(executor.count('select:guild_thread_state')).toBe(0); + }); + + it('indexes forums separately and merges them only for active or tainted guilds', async () => { + await repositories.channelData.upsert(channelRow(FORUM_ID, ChannelTypes.GUILD_FORUM)); + expect( + await fetchMany(ThreadOnlyChannelsByGuild.selectCql({where: ThreadOnlyChannelsByGuild.where.eq('guild_id')}), { + guild_id: GUILD_ID, + }), + ).toHaveLength(1); + expect((await repositories.channelData.listGuildChannels(GUILD_ID, 'enrolled')).map((c) => c.id)).toEqual([ + PARENT_ID, + ]); + expect((await repositories.channelData.listGuildChannels(GUILD_ID, 'maintenance')).map((c) => c.id)).toEqual([ + PARENT_ID, + ]); + await taint(); + expect( + new Set((await repositories.channelData.listGuildChannels(GUILD_ID, 'enrolled')).map((c) => c.id)), + ).toEqual(new Set([PARENT_ID, FORUM_ID])); + setThreadsConfig({enabled: false, ever_enabled: true}); + expect((await repositories.channelData.listGuildChannels(GUILD_ID, 'enrolled')).map((c) => c.id)).toEqual([ + PARENT_ID, + ]); + expect( + new Set((await repositories.channelData.listGuildChannels(GUILD_ID, 'maintenance')).map((c) => c.id)), + ).toEqual(new Set([PARENT_ID, FORUM_ID])); + await repositories.channelData.delete(FORUM_ID, GUILD_ID, ChannelTypes.GUILD_FORUM); + expect( + await fetchMany(ThreadOnlyChannelsByGuild.selectCql({where: ThreadOnlyChannelsByGuild.where.eq('guild_id')}), { + guild_id: GUILD_ID, + }), + ).toEqual([]); + }); + + it('purges every thread row with threads_by_parent last', async () => { + const threadId = freshThreadId(); + await repositories.threads.create(createParams(threadId, {members: [{userId: OWNER_ID, flags: 1}]})); + await repositories.threads.updateState(threadId, () => ({archived: true})); + executor.statements.length = 0; + await repositories.threads.purgeThread(threadId); + expect(executor.statements.at(-1)).toBe('delete:threads_by_parent'); + expect(await repositories.threads.getState(threadId)).toBeNull(); + expect(await repositories.channelData.findUnique(threadId)).toBeNull(); + expect(await repositories.threads.listMembers(threadId, {limit: 10})).toEqual([]); + expect(await repositories.threads.listJoinedThreadIds(OWNER_ID, GUILD_ID)).toEqual([]); + expect((await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 10})).threads).toEqual([]); + expect(await repositories.threads.listThreadIdsByParent(PARENT_ID, {limit: 10})).toEqual([]); + }); + + it('stores parent config through plain patches', async () => { + await repositories.threads.patchParentConfig(GUILD_ID, FORUM_ID, { + flags: ChannelFlags.REQUIRE_TAG, + available_tags: [{id: 5n, name: 'bug', moderated: false, emoji_id: null, emoji_name: '🐛'}], + default_sort_order: 1, + }); + const config = await repositories.threads.getParentConfig(GUILD_ID, FORUM_ID); + expect(config?.flags).toBe(ChannelFlags.REQUIRE_TAG); + expect(config?.availableTags.map((tag) => tag.toUdt())).toEqual([ + {id: 5n, name: 'bug', moderated: false, emoji_id: null, emoji_name: '🐛'}, + ]); + await repositories.threads.patchParentConfig(GUILD_ID, FORUM_ID, {available_tags: []}); + expect((await repositories.threads.getParentConfig(GUILD_ID, FORUM_ID))?.availableTags).toEqual([]); + expect(await repositories.threads.listParentConfigs(GUILD_ID)).toHaveLength(1); + await repositories.threads.deleteParentConfig(GUILD_ID, FORUM_ID); + expect( + await fetchOne( + ThreadParentConfig.selectCql({ + where: [ThreadParentConfig.where.eq('guild_id'), ThreadParentConfig.where.eq('channel_id')], + }), + {guild_id: GUILD_ID, channel_id: FORUM_ID}, + ), + ).toBeNull(); + }); + }); +} + +const THREAD_TABLES = [ + ThreadState, + ThreadStats, + ThreadsByParent, + ActiveThreadsByGuild, + ArchivedThreadsByParent, + ThreadMembers, + ThreadMembersByUser, + ThreadParentConfig, + ForumPinnedThread, + ThreadOnlyChannelsByGuild, + GuildThreadState, +]; + +describe('thread storage leaves control storage untouched', () => { + it('keeps the channel and message column lists byte-identical', () => { + expect([...CHANNEL_COLUMNS]).toEqual([ + 'channel_id', + 'guild_id', + 'type', + 'name', + 'topic', + 'icon_hash', + 'url', + 'parent_id', + 'position', + 'owner_id', + 'recipient_ids', + 'nsfw', + 'content_warning_level', + 'content_warning_text', + 'rate_limit_per_user', + 'bitrate', + 'user_limit', + 'voice_connection_limit', + 'rtc_region', + 'last_message_id', + 'last_pin_timestamp', + 'permission_overwrites', + 'nicks', + 'soft_deleted', + 'indexed_at', + 'version', + ]); + expect([...MESSAGE_COLUMNS]).toEqual([ + 'channel_id', + 'bucket', + 'message_id', + 'author_id', + 'type', + 'webhook_id', + 'webhook_name', + 'webhook_avatar_hash', + 'content', + 'edited_timestamp', + 'pinned_timestamp', + 'flags', + 'mention_everyone', + 'mention_users', + 'mention_roles', + 'mention_channels', + 'attachments', + 'embeds', + 'sticker_items', + 'message_reference', + 'message_snapshots', + 'call', + 'has_reaction', + 'version', + ]); + }); + + it('declares every thread table in the target schema with the same columns and no TTL', () => { + const schema = JSON.parse( + readFileSync( + fileURLToPath(new URL('../../../../../tools/dev/cassandra_target_schema.json', import.meta.url)), + 'utf8', + ), + ) as {tables: Array<{name: string; columns: Array<{name: string}>; options: string}>}; + for (const table of THREAD_TABLES) { + const declared = schema.tables.find((entry) => entry.name === table.name); + expect(declared, table.name).toBeDefined(); + expect(new Set(declared!.columns.map((column) => column.name))).toEqual(new Set(table.columns)); + expect(declared!.options).not.toContain('default_time_to_live'); + expect(table.defaultTtlSeconds).toBeUndefined(); + } + }); +}); + +describeThreadRepository('in-memory cassandra', async () => new InMemoryCassandraQueryExecutor()); + +const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0; +const KV_TABLE = 'kv_thread_repository'; +const CONTAINER = `fluxer-threads-${process.pid.toString(36)}-${Date.now().toString(36)}`; + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (typeof address === 'string' || address === null) { + reject(new Error('no port')); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); +} + +describe.skipIf(!dockerAvailable)('ThreadRepository against postgres kv', () => { + beforeAll(async () => { + const port = await freePort(); + startDockerContainer([ + 'run', + '-d', + '--name', + CONTAINER, + '-e', + 'POSTGRES_USER=fluxer', + '-e', + 'POSTGRES_PASSWORD=fluxer', + '-e', + 'POSTGRES_DB=fluxer', + '-p', + `127.0.0.1:${port}:5432`, + 'postgres:16-alpine', + '-c', + 'fsync=off', + ]); + let ready = false; + for (let attempt = 0; attempt < 180 && !ready; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 500)); + const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], { + stdio: 'ignore', + }); + if (probe.status !== 0) continue; + try { + await initPostgres({ + url: `postgres://fluxer:fluxer@127.0.0.1:${port}/fluxer`, + maxConnections: 8, + kvTable: KV_TABLE, + }); + await getDefaultPostgresClient().query('SELECT 1'); + ready = true; + } catch { + await shutdownPostgres().catch(() => {}); + } + } + if (!ready) throw new Error('postgres never came up'); + await ensurePostgresKvSchema(getDefaultPostgresClient()); + }, 900_000); + + afterAll(async () => { + setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor()); + await shutdownPostgres().catch(() => {}); + spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'}); + }); + + describeThreadRepository('postgres kv', async () => { + const client = getDefaultPostgresClient(); + await client.query(`DELETE FROM ${KV_TABLE}`); + return new PostgresKvQueryExecutor(client); + }); +}); diff --git a/fluxer_api/src/api/channel/repositories/ThreadRepository.ts b/fluxer_api/src/api/channel/repositories/ThreadRepository.ts new file mode 100644 index 000000000..b0815d2b8 --- /dev/null +++ b/fluxer_api/src/api/channel/repositories/ThreadRepository.ts @@ -0,0 +1,1104 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type ChannelID, channelIdToMessageId, type GuildID, type MessageID, type UserID} from '@app/api/BrandedTypes'; +import type {ChannelDataRepository} from '@app/api/channel/repositories/ChannelDataRepository'; +import type {IMessageRepository} from '@app/api/channel/repositories/IMessageRepository'; +import { + type ArchivedThreadPage, + type CreateThreadMember, + type CreateThreadParams, + IThreadRepository, + type ThreadMemberAddResult, + type ThreadMemberRemoveResult, + type ThreadMemberSettingsPatch, + type ThreadParentConfigPatch, + type ThreadStatePatch, + type ThreadStateTransition, +} from '@app/api/channel/repositories/IThreadRepository'; +import { + BatchBuilder, + deleteOneOrMany, + executeConditional, + fetchMany, + fetchManyInChunks, + fetchOne, + upsertOne, +} from '@app/api/database/CassandraQueryExecution'; +import {Db, type DbOp, type PreparedQuery} from '@app/api/database/CassandraTypes'; +import type {ChannelRow} from '@app/api/database/types/ChannelTypes'; +import type { + ActiveThreadsByGuildRow, + ArchivedThreadsByParentRow, + GuildThreadStateRow, + ThreadMemberRow, + ThreadMembersByUserRow, + ThreadParentConfigRow, + ThreadStateRow, + ThreadStatsRow, + ThreadsByParentRow, +} from '@app/api/database/types/ThreadTypes'; +import {insertGuildThreadMarker, isTainted} from '@app/api/experiment/ChannelThreadsGate'; +import {Logger} from '@app/api/Logger'; +import {ThreadMember} from '@app/api/models/ThreadMember'; +import {ThreadParentConfig} from '@app/api/models/ThreadParentConfig'; +import {ThreadState} from '@app/api/models/ThreadState'; +import {ThreadStats} from '@app/api/models/ThreadStats'; +import { + ActiveThreadsByGuild, + ArchivedThreadsByParent, + Channels, + ForumPinnedThread, + GuildThreadState, + ThreadMembers, + ThreadMembersByUser, + ThreadOnlyChannelsByGuild, + ThreadParentConfig as ThreadParentConfigTable, + ThreadState as ThreadStateTable, + ThreadStats as ThreadStatsTable, + ThreadsByParent, +} from '@app/api/Tables'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import { + ChannelFlags, + MAX_THREAD_MEMBERS, + THREAD_MEMBER_IDS_PREVIEW_SIZE, + THREAD_ONLY_CHANNEL_TYPES, + THREAD_PARENT_CHANNEL_TYPES, +} from '@fluxer/constants/src/ThreadConstants'; +import {MaxThreadMembersError} from '@fluxer/errors/src/domains/channel/MaxThreadMembersError'; +import {ThreadAlreadyCreatedForMessageError} from '@fluxer/errors/src/domains/channel/ThreadAlreadyCreatedForMessageError'; +import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; +import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; + +const STATE_CAS_ATTEMPTS = 3; +const DANGLING_CREATE_REPAIR_MS = 30_000; +const ENUMERATION_PAGE_SIZE = 1000; +const MEMBER_PAGE_SIZE = 1000; + +const FETCH_STATE = ThreadStateTable.select({where: ThreadStateTable.where.eq('thread_id'), limit: 1}); +const FETCH_STATES = ThreadStateTable.select({where: ThreadStateTable.where.in('thread_id', 'thread_ids')}); +const FETCH_STATS = ThreadStatsTable.select({where: ThreadStatsTable.where.eq('thread_id'), limit: 1}); +const FETCH_STATS_MANY = ThreadStatsTable.select({where: ThreadStatsTable.where.in('thread_id', 'thread_ids')}); +const FETCH_CHANNEL = Channels.select({ + where: [Channels.where.eq('channel_id'), Channels.where.eq('soft_deleted')], + limit: 1, +}); +const FETCH_CHANNELS = Channels.select({ + columns: ['channel_id', 'last_message_id'], + where: [Channels.where.in('channel_id', 'channel_ids'), Channels.where.eq('soft_deleted')], +}); +const FETCH_FORUM_PIN = ForumPinnedThread.select({where: ForumPinnedThread.where.eq('parent_id'), limit: 1}); +const FETCH_ACTIVE = ActiveThreadsByGuild.select({where: ActiveThreadsByGuild.where.eq('guild_id')}); +const COUNT_ACTIVE = ActiveThreadsByGuild.selectCount({where: ActiveThreadsByGuild.where.eq('guild_id')}); +const FETCH_THREADS_BY_PARENT_FIRST = ThreadsByParent.select({ + where: ThreadsByParent.where.eq('parent_id'), + limit: ENUMERATION_PAGE_SIZE, +}); +const FETCH_THREADS_BY_PARENT_AFTER = ThreadsByParent.select({ + where: [ThreadsByParent.where.eq('parent_id'), ThreadsByParent.where.gt('thread_id', 'after')], + limit: ENUMERATION_PAGE_SIZE, +}); +const FETCH_MEMBER = ThreadMembers.select({ + where: [ThreadMembers.where.eq('thread_id'), ThreadMembers.where.eq('user_id')], + limit: 1, +}); +const FETCH_MEMBERS_BY_IDS = ThreadMembers.select({ + where: [ThreadMembers.where.eq('thread_id'), ThreadMembers.where.in('user_id', 'user_ids')], +}); +const FETCH_JOINED_BY_GUILD = ThreadMembersByUser.select({ + where: [ThreadMembersByUser.where.eq('user_id'), ThreadMembersByUser.where.eq('guild_id')], +}); +const FETCH_PARENT_CONFIG = ThreadParentConfigTable.select({ + where: [ThreadParentConfigTable.where.eq('guild_id'), ThreadParentConfigTable.where.eq('channel_id')], + limit: 1, +}); +const FETCH_GUILD_MARKER = GuildThreadState.select({where: GuildThreadState.where.eq('guild_id'), limit: 1}); +const FETCH_PARENT_CONFIGS = ThreadParentConfigTable.select({where: ThreadParentConfigTable.where.eq('guild_id')}); + +function archivedQuery(limit: number, bounded: boolean) { + return ArchivedThreadsByParent.select({ + where: bounded + ? [ + ArchivedThreadsByParent.where.eq('parent_id'), + ArchivedThreadsByParent.where.eq('is_private'), + ArchivedThreadsByParent.where.lte('archive_timestamp', 'before'), + ] + : [ArchivedThreadsByParent.where.eq('parent_id'), ArchivedThreadsByParent.where.eq('is_private')], + orderBy: {col: 'archive_timestamp', direction: 'DESC'}, + limit, + }); +} + +function joinedPrivateQuery(limit: number, bounded: boolean) { + const base = [ + ThreadMembersByUser.where.eq('user_id'), + ThreadMembersByUser.where.eq('guild_id'), + ThreadMembersByUser.where.eq('parent_id'), + ThreadMembersByUser.where.eq('is_private'), + ]; + return ThreadMembersByUser.select({ + where: bounded ? [...base, ThreadMembersByUser.where.lt('thread_id', 'before')] : base, + orderBy: {col: 'thread_id', direction: 'DESC'}, + limit, + }); +} + +function membersQuery(limit: number, bounded: boolean) { + return ThreadMembers.select({ + where: bounded + ? [ThreadMembers.where.eq('thread_id'), ThreadMembers.where.gt('user_id', 'after')] + : ThreadMembers.where.eq('thread_id'), + limit, + }); +} + +function isPrivateType(type: number): boolean { + return type === ChannelTypes.PRIVATE_THREAD; +} + +function sameTime(left: Date | null, right: Date | null): boolean { + if (left === null || right === null) return left === right; + return left.getTime() === right.getTime(); +} + +function sameValue(left: unknown, right: unknown): boolean { + if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime(); + if (Array.isArray(left) || Array.isArray(right)) { + const a = (left as Array | null) ?? []; + const b = (right as Array | null) ?? []; + return a.length === b.length && a.every((value, index) => value === b[index]); + } + return left === right; +} + +function previewWith(preview: Array, added: Array): Array { + const fresh = [...added].reverse(); + const kept = preview.filter((id) => !added.includes(id)); + return [...fresh, ...kept].slice(0, THREAD_MEMBER_IDS_PREVIEW_SIZE); +} + +function listOrNull(values: Array): Array | null { + return values.length > 0 ? values : null; +} + +type StatePatchOps = Partial<{[K in Exclude]: DbOp}>; + +function toStateOps(patch: ThreadStatePatch): StatePatchOps { + const ops: StatePatchOps = {}; + for (const [key, value] of Object.entries(patch) as Array<[keyof ThreadStatePatch, unknown]>) { + if (value === undefined) continue; + if (key === 'applied_tags') { + const tags = value as Array | null; + ops.applied_tags = tags && tags.length > 0 ? Db.set(tags) : Db.clear(); + continue; + } + (ops as Record>)[key] = value === null ? Db.clear() : Db.set(value); + } + return ops; +} + +export class ThreadRepository extends IThreadRepository { + constructor( + private readonly channelData: ChannelDataRepository, + private readonly messages: IMessageRepository, + private readonly onIndexDrift?: (threadIds: Array) => void, + ) { + super(); + } + + async getState(threadId: ChannelID): Promise { + const row = await fetchOne(FETCH_STATE.bind({thread_id: threadId})); + return row ? new ThreadState(row) : null; + } + + async getStates(threadIds: Array): Promise> { + const rows = await fetchManyInChunks(FETCH_STATES, threadIds, (chunk) => ({thread_ids: chunk})); + const byId = new Map(rows.map((row) => [row.thread_id, new ThreadState(row)])); + return threadIds.flatMap((id) => { + const state = byId.get(id); + return state ? [state] : []; + }); + } + + async getStats(threadId: ChannelID): Promise { + const row = await fetchOne(FETCH_STATS.bind({thread_id: threadId})); + return row ? new ThreadStats(row) : ThreadStats.empty(threadId); + } + + async getStatsMany(threadIds: Array): Promise> { + const rows = await fetchManyInChunks(FETCH_STATS_MANY, threadIds, (chunk) => ({ + thread_ids: chunk, + })); + const result = new Map(); + for (const id of threadIds) result.set(id, ThreadStats.empty(id)); + for (const row of rows) result.set(row.thread_id, new ThreadStats(row)); + return result; + } + + async adjustMessageCount(threadId: ChannelID, delta: number): Promise { + if (delta === 0) return; + await this.channelData.adjustThreadStats(threadId, delta, 0); + } + + async create(params: CreateThreadParams): Promise { + const channel = params.channel; + const threadId = channel.channel_id; + const guildId = channel.guild_id; + const parentId = channel.parent_id; + if (guildId == null || parentId == null) { + throw new Error('Thread channel rows require a guild and a parent'); + } + await upsertOne( + ThreadsByParent.upsertAll({parent_id: parentId, thread_id: threadId, guild_id: guildId, type: channel.type}), + ); + const memberIds = params.members.map((member) => member.userId); + const stateRow: ThreadStateRow = { + thread_id: threadId, + guild_id: guildId, + parent_id: parentId, + type: channel.type, + archived: false, + locked: false, + invitable: isPrivateType(channel.type) ? (params.invitable ?? true) : null, + auto_archive_duration: params.autoArchiveDuration, + archive_timestamp: params.createdAt, + created_at: params.createdAt, + flags: params.flags, + applied_tags: listOrNull(params.appliedTags), + member_count: memberIds.length, + member_ids_preview: listOrNull(previewWith([], memberIds)), + has_starter: params.hasStarter, + state_version: 1, + }; + const claim = await this.claimState(stateRow); + if (claim === null) { + throw new ThreadAlreadyCreatedForMessageError(); + } + if (claim.repaired) { + await this.purgeMembers(threadId, claim.repaired.type); + } + const members = [...new Map(params.members.map((member) => [member.userId, member])).values()]; + if (members.length > 0) { + const inserted = await executeConditional( + ThreadMembers.conditionalBatch( + members.map((member) => ({ + action: 'insert' as const, + row: this.memberRow(stateRow, member, params.createdAt), + })), + ), + ); + if (!inserted) throw new ServiceUnavailableError(); + } + const batch = new BatchBuilder(); + batch.addPrepared(Channels.upsertAll(channel)); + batch.addPrepared( + ActiveThreadsByGuild.upsertAll({guild_id: guildId, thread_id: threadId, parent_id: parentId, type: channel.type}), + ); + for (const member of members) { + batch.addPrepared(ThreadMembersByUser.upsertAll(this.memberByUserRow(stateRow, member.userId))); + } + batch.addPrepared( + ThreadParentConfigTable.patchByPk({guild_id: guildId, channel_id: parentId}, {has_threads: Db.set(true)}), + ); + await batch.execute(); + if (THREAD_ONLY_CHANNEL_TYPES.has(params.parentType)) { + await this.channelData.updateLastMessageId(parentId, channelIdToMessageId(threadId)); + } + return new ThreadState(stateRow); + } + + private async claimState(row: ThreadStateRow): Promise<{repaired: ThreadStateRow | null} | null> { + if (await executeConditional(ThreadStateTable.insertIfNotExists(row))) return {repaired: null}; + const existing = await fetchOne(FETCH_STATE.bind({thread_id: row.thread_id})); + if (!existing) { + return (await executeConditional(ThreadStateTable.insertIfNotExists(row))) ? {repaired: null} : null; + } + if (Date.now() - existing.created_at.getTime() < DANGLING_CREATE_REPAIR_MS) return null; + const channel = await fetchOne(FETCH_CHANNEL.bind({channel_id: row.thread_id, soft_deleted: false})); + if (channel) return null; + const cleared = await executeConditional( + ThreadStateTable.conditionalDeleteByPk({thread_id: row.thread_id}, {state_version: existing.state_version}), + ); + if (!cleared) return null; + Logger.warn({threadId: row.thread_id.toString()}, 'Repaired a dangling thread create'); + return (await executeConditional(ThreadStateTable.insertIfNotExists(row))) ? {repaired: existing} : null; + } + + private memberRow(state: ThreadStateRow, member: CreateThreadMember, joinTimestamp: Date): ThreadMemberRow { + return { + thread_id: state.thread_id, + user_id: member.userId, + guild_id: state.guild_id, + parent_id: state.parent_id, + join_timestamp: joinTimestamp, + flags: member.flags, + muted: false, + mute_config: null, + }; + } + + private memberByUserRow( + state: Pick, + userId: UserID, + ): ThreadMembersByUserRow { + return { + user_id: userId, + guild_id: state.guild_id, + parent_id: state.parent_id, + is_private: isPrivateType(state.type), + thread_id: state.thread_id, + }; + } + + async updateState( + threadId: ChannelID, + mutate: (current: ThreadState) => ThreadStatePatch | null, + ): Promise { + for (let attempt = 0; attempt < STATE_CAS_ATTEMPTS; attempt++) { + const current = await this.getState(threadId); + if (!current) return null; + const requested = mutate(current); + if (requested === null) return {previous: current, state: current}; + const patch = this.withArchiveRules(current, requested); + if (Object.keys(patch).length === 0) return {previous: current, state: current}; + const nextRow: ThreadStateRow = {...current.toRow(), ...patch, state_version: current.stateVersion + 1}; + const applied = await executeConditional( + ThreadStateTable.conditionalPatchByPk( + {thread_id: threadId}, + {...toStateOps(patch), state_version: Db.set(current.stateVersion + 1)}, + {state_version: current.stateVersion}, + ), + ); + if (!applied) continue; + const next = new ThreadState(nextRow); + await this.moveIndexes(current, next); + return {previous: current, state: next}; + } + throw new ServiceUnavailableError(); + } + + private withArchiveRules(current: ThreadState, requested: ThreadStatePatch): ThreadStatePatch { + const patch: ThreadStatePatch = {}; + for (const [key, value] of Object.entries(requested) as Array<[keyof ThreadStatePatch, unknown]>) { + if (value === undefined) continue; + if (sameValue(current.toRow()[key], value)) continue; + (patch as Record)[key] = value; + } + const archiving = patch.archived === true && !current.archived; + const unarchiving = patch.archived === false && current.archived; + if ((archiving || unarchiving) && patch.archive_timestamp === undefined) { + patch.archive_timestamp = new Date(); + } + if (archiving) { + const flags = patch.flags ?? current.flags; + if ((flags & ChannelFlags.PINNED) !== 0) patch.flags = flags & ~ChannelFlags.PINNED; + } + return patch; + } + + private async moveIndexes(previous: ThreadState, next: ThreadState): Promise { + const batch = new BatchBuilder(); + const timestampMoved = !sameTime(previous.archiveTimestamp, next.archiveTimestamp); + if (previous.archived && previous.archiveTimestamp && (!next.archived || timestampMoved)) { + batch.addPrepared(ArchivedThreadsByParent.deleteByPk(this.archivedKey(previous, previous.archiveTimestamp))); + } + if (next.archived && next.archiveTimestamp && (!previous.archived || timestampMoved)) { + batch.addPrepared( + ArchivedThreadsByParent.upsertAll({ + ...this.archivedKey(next, next.archiveTimestamp), + guild_id: next.guildId, + }), + ); + } + if (!previous.archived && next.archived) { + batch.addPrepared(ActiveThreadsByGuild.deleteByPk({guild_id: next.guildId, thread_id: next.threadId})); + } + if (previous.archived && !next.archived) { + batch.addPrepared(ActiveThreadsByGuild.upsertAll(this.activeRow(next))); + } + await batch.execute(false); + if (previous.isPinned && !next.isPinned) { + await this.releaseForumPin(next.parentId, next.threadId); + } + const latest = await this.getState(next.threadId); + if (latest && latest.stateVersion !== next.stateVersion) { + await this.repairThreadIndexes([next.threadId]); + } + } + + private archivedKey( + state: ThreadState, + archiveTimestamp: Date, + ): Pick { + return { + parent_id: state.parentId, + is_private: state.isPrivate, + archive_timestamp: archiveTimestamp, + thread_id: state.threadId, + }; + } + + private activeRow(state: ThreadState): ActiveThreadsByGuildRow { + return {guild_id: state.guildId, thread_id: state.threadId, parent_id: state.parentId, type: state.type}; + } + + async claimForumPin(parentId: ChannelID, threadId: ChannelID): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + if (await executeConditional(ForumPinnedThread.insertIfNotExists({parent_id: parentId, thread_id: threadId}))) { + return true; + } + const holder = await this.getForumPin(parentId); + if (holder === null) continue; + if (holder === threadId) return true; + const holderState = await this.getState(holder); + if (holderState?.isPinned && !holderState.archived) return false; + await executeConditional(ForumPinnedThread.conditionalDeleteByPk({parent_id: parentId}, {thread_id: holder})); + } + return false; + } + + async releaseForumPin(parentId: ChannelID, threadId: ChannelID): Promise { + await executeConditional(ForumPinnedThread.conditionalDeleteByPk({parent_id: parentId}, {thread_id: threadId})); + } + + async getForumPin(parentId: ChannelID): Promise { + const row = await fetchOne<{parent_id: ChannelID; thread_id: ChannelID}>( + FETCH_FORUM_PIN.bind({parent_id: parentId}), + ); + return row?.thread_id ?? null; + } + + async countActiveThreads(guildId: GuildID): Promise { + const row = await fetchOne<{count: bigint | number}>(COUNT_ACTIVE.bind({guild_id: guildId})); + return Number(row?.count ?? 0); + } + + async listActiveThreads(guildId: GuildID): Promise> { + const rows = await fetchMany(FETCH_ACTIVE.bind({guild_id: guildId})); + if (rows.length === 0) return []; + const states = await this.getStates(rows.map((row) => row.thread_id)); + const byId = new Map(states.map((state) => [state.threadId, state])); + const result: Array = []; + const stale: Array = []; + for (const row of rows) { + const state = byId.get(row.thread_id); + if (state && !state.archived && state.guildId === guildId) { + result.push(state); + } else { + stale.push(row); + } + } + if (stale.length > 0) { + await this.dropStaleRows(stale.map((row) => ActiveThreadsByGuild.deleteByPk(row))); + this.reportDrift(stale.filter((row) => byId.has(row.thread_id)).map((row) => row.thread_id)); + } + return result; + } + + async listArchivedThreads( + parentId: ChannelID, + isPrivate: boolean, + opts: {before?: Date; limit: number}, + ): Promise { + const want = opts.limit + 1; + const collected: Array = []; + const seen = new Set(); + let before = opts.before ? new Date(opts.before.getTime() - 1) : null; + let pageSize = want; + for (;;) { + const query = archivedQuery(pageSize, before !== null); + const rows = await fetchMany( + query.bind( + before !== null + ? {parent_id: parentId, is_private: isPrivate, before} + : {parent_id: parentId, is_private: isPrivate}, + ), + ); + const fresh = rows.filter((row) => !seen.has(row.thread_id)); + for (const row of fresh) seen.add(row.thread_id); + const states = await this.getStates(fresh.map((row) => row.thread_id)); + const byId = new Map(states.map((state) => [state.threadId, state])); + const stale: Array = []; + for (const row of fresh) { + const state = byId.get(row.thread_id); + if ( + state?.archived && + state.parentId === parentId && + state.isPrivate === isPrivate && + sameTime(state.archiveTimestamp, row.archive_timestamp) + ) { + if (collected.length < want) collected.push(state); + } else { + stale.push(row); + } + } + if (stale.length > 0) { + await this.dropStaleRows(stale.map((row) => ArchivedThreadsByParent.deleteByPk(row))); + this.reportDrift(stale.filter((row) => byId.has(row.thread_id)).map((row) => row.thread_id)); + } + if (collected.length >= want || rows.length < pageSize) break; + const last = rows[rows.length - 1]!.archive_timestamp; + before = fresh.length === 0 ? new Date(last.getTime() - 1) : last; + pageSize = Math.min(pageSize * 2, 1000); + } + return {threads: collected.slice(0, opts.limit), hasMore: collected.length > opts.limit}; + } + + async listJoinedPrivateArchivedThreads( + userId: UserID, + guildId: GuildID, + parentId: ChannelID, + opts: {before?: ChannelID; limit: number}, + ): Promise { + const want = opts.limit + 1; + const collected: Array = []; + let before: ChannelID | null = opts.before ?? null; + let pageSize = want; + for (;;) { + const base = {user_id: userId, guild_id: guildId, parent_id: parentId, is_private: true}; + const rows = await fetchMany( + joinedPrivateQuery(pageSize, before !== null).bind(before !== null ? {...base, before} : base), + ); + const states = await this.getStates(rows.map((row) => row.thread_id)); + for (const state of states) { + if (state.archived && state.isPrivate && collected.length < want) collected.push(state); + } + if (collected.length >= want || rows.length < pageSize) break; + before = rows[rows.length - 1]!.thread_id; + pageSize = Math.min(pageSize * 2, 1000); + } + return {threads: collected.slice(0, opts.limit), hasMore: collected.length > opts.limit}; + } + + async listJoinedPrivateThreadIds( + userId: UserID, + guildId: GuildID, + parentId: ChannelID, + limit: number, + ): Promise> { + const rows = await fetchMany( + joinedPrivateQuery(limit, false).bind({ + user_id: userId, + guild_id: guildId, + parent_id: parentId, + is_private: true, + }), + ); + return rows.map((row) => row.thread_id); + } + + async listJoinedThreadIds(userId: UserID, guildId: GuildID): Promise> { + const rows = await fetchMany( + FETCH_JOINED_BY_GUILD.bind({user_id: userId, guild_id: guildId}), + ); + return rows.map((row) => row.thread_id); + } + + async repairThreadIndexes(threadIds: Array): Promise { + const states = await this.getStates(threadIds); + for (const state of states) { + const batch = new BatchBuilder(); + if (state.archived) { + batch.addPrepared(ActiveThreadsByGuild.deleteByPk({guild_id: state.guildId, thread_id: state.threadId})); + if (state.archiveTimestamp) { + batch.addPrepared( + ArchivedThreadsByParent.upsertAll({ + ...this.archivedKey(state, state.archiveTimestamp), + guild_id: state.guildId, + }), + ); + } + } else { + batch.addPrepared(ActiveThreadsByGuild.upsertAll(this.activeRow(state))); + } + batch.addPrepared( + ThreadsByParent.upsertAll({ + parent_id: state.parentId, + thread_id: state.threadId, + guild_id: state.guildId, + type: state.type, + }), + ); + await batch.execute(false); + } + } + + private async dropStaleRows(deletes: Array): Promise { + try { + const batch = new BatchBuilder(); + for (const query of deletes) batch.addPrepared(query); + await batch.execute(false); + } catch (error) { + Logger.warn( + {error: error instanceof Error ? error.message : String(error)}, + 'Failed to drop stale thread index rows', + ); + } + } + + private reportDrift(threadIds: Array): void { + if (threadIds.length === 0 || !this.onIndexDrift) return; + try { + this.onIndexDrift(threadIds); + } catch (error) { + Logger.warn({error: error instanceof Error ? error.message : String(error)}, 'Thread index drift hook failed'); + } + } + + async getMember(threadId: ChannelID, userId: UserID): Promise { + const row = await fetchOne(FETCH_MEMBER.bind({thread_id: threadId, user_id: userId})); + return row ? new ThreadMember(row) : null; + } + + async getMembers(threadId: ChannelID, userIds: Array): Promise> { + if (userIds.length === 0) return []; + const rows = await fetchManyInChunks(FETCH_MEMBERS_BY_IDS, userIds, (chunk) => ({ + thread_id: threadId, + user_ids: chunk, + })); + return rows.map((row) => new ThreadMember(row)); + } + + async listMembers(threadId: ChannelID, opts: {after?: UserID; limit: number}): Promise> { + const bounded = opts.after !== undefined; + const rows = await fetchMany( + membersQuery(opts.limit, bounded).bind( + bounded ? {thread_id: threadId, after: opts.after as UserID} : {thread_id: threadId}, + ), + ); + return rows.map((row) => new ThreadMember(row)); + } + + async addMembers( + threadId: ChannelID, + members: Array, + opts?: {joinTimestamp?: Date}, + ): Promise { + const unique = [...new Map(members.map((member) => [member.userId, member])).values()]; + const joinTimestamp = opts?.joinTimestamp ?? new Date(); + for (let attempt = 0; attempt < STATE_CAS_ATTEMPTS; attempt++) { + const state = await this.getState(threadId); + if (!state) return null; + if (unique.length === 0) return {added: [], state}; + const existing = new Set( + ( + await this.getMembers( + threadId, + unique.map((m) => m.userId), + ) + ).map((m) => m.userId), + ); + const toAdd = unique.filter((member) => !existing.has(member.userId)); + if (toAdd.length === 0) return {added: [], state}; + if (state.memberCount + toAdd.length > MAX_THREAD_MEMBERS) { + throw new MaxThreadMembersError(MAX_THREAD_MEMBERS); + } + const stateRow = state.toRow(); + const rows = toAdd.map((member) => this.memberRow(stateRow, member, joinTimestamp)); + const inserted = await executeConditional( + ThreadMembers.conditionalBatch(rows.map((row) => ({action: 'insert' as const, row}))), + ); + if (!inserted) continue; + const next = await this.bumpMembership( + threadId, + rows.map((row) => row.user_id), + [], + ).catch(async (error: unknown) => { + await executeConditional( + ThreadMembers.conditionalBatch( + rows.map((row) => ({ + action: 'delete' as const, + pk: {thread_id: threadId, user_id: row.user_id}, + expected: {join_timestamp: row.join_timestamp}, + })), + ), + ); + throw error; + }); + const byUser = new BatchBuilder(); + for (const row of rows) + byUser.addPrepared(ThreadMembersByUser.upsertAll(this.memberByUserRow(stateRow, row.user_id))); + await byUser.execute(false); + return {added: rows.map((row) => new ThreadMember(row)), state: next ?? state}; + } + throw new ServiceUnavailableError(); + } + + async removeMembers(threadId: ChannelID, userIds: Array): Promise { + const unique = [...new Set(userIds)]; + for (let attempt = 0; attempt < STATE_CAS_ATTEMPTS; attempt++) { + const existing = await this.getMembers(threadId, unique); + if (existing.length === 0) return {removed: [], state: await this.getState(threadId)}; + const removed = await executeConditional( + ThreadMembers.conditionalBatch( + existing.map((member) => ({ + action: 'delete' as const, + pk: {thread_id: threadId, user_id: member.userId}, + expected: {join_timestamp: member.joinTimestamp}, + })), + ), + ); + if (!removed) continue; + const state = await this.bumpMembership( + threadId, + [], + existing.map((member) => member.userId), + ).catch(async (error: unknown) => { + await executeConditional( + ThreadMembers.conditionalBatch(existing.map((member) => ({action: 'insert' as const, row: member.toRow()}))), + ); + throw error; + }); + const byUser = new BatchBuilder(); + const types = state ? [state.type] : [ChannelTypes.PUBLIC_THREAD, ChannelTypes.PRIVATE_THREAD]; + for (const member of existing) { + for (const type of types) { + byUser.addPrepared( + ThreadMembersByUser.deleteByPk( + this.memberByUserRow( + {thread_id: threadId, guild_id: member.guildId, parent_id: member.parentId, type}, + member.userId, + ), + ), + ); + } + } + await byUser.execute(false); + return {removed: existing, state}; + } + throw new ServiceUnavailableError(); + } + + private async bumpMembership( + threadId: ChannelID, + added: Array, + removed: Array, + ): Promise { + for (let attempt = 0; attempt < STATE_CAS_ATTEMPTS * 2; attempt++) { + const state = await this.getState(threadId); + if (!state) return null; + const memberCount = Math.max(0, state.memberCount + added.length - removed.length); + if (added.length > 0 && memberCount > MAX_THREAD_MEMBERS) throw new MaxThreadMembersError(MAX_THREAD_MEMBERS); + const preview = previewWith(state.memberIdsPreview, added).filter((id) => !removed.includes(id)); + const applied = await executeConditional( + ThreadStateTable.conditionalPatchByPk( + {thread_id: threadId}, + { + member_count: Db.set(memberCount), + member_ids_preview: preview.length > 0 ? Db.set(preview) : Db.clear(), + state_version: Db.set(state.stateVersion + 1), + }, + {state_version: state.stateVersion}, + ), + ); + if (applied) { + return new ThreadState({ + ...state.toRow(), + member_count: memberCount, + member_ids_preview: listOrNull(preview), + state_version: state.stateVersion + 1, + }); + } + } + throw new ServiceUnavailableError(); + } + + async updateMemberSettings(expected: ThreadMember, patch: ThreadMemberSettingsPatch): Promise { + const ops: Partial<{flags: DbOp; muted: DbOp; mute_config: DbOp}> = + {}; + if (patch.flags !== undefined) ops.flags = Db.set(patch.flags); + if (patch.muted !== undefined) ops.muted = Db.set(patch.muted); + if (patch.muteConfig !== undefined) ops.mute_config = patch.muteConfig ? Db.set(patch.muteConfig) : Db.clear(); + if (Object.keys(ops).length === 0) return expected; + const applied = await executeConditional( + ThreadMembers.conditionalPatchByPk({thread_id: expected.threadId, user_id: expected.userId}, ops, { + join_timestamp: expected.joinTimestamp, + flags: expected.flags, + muted: expected.muted, + }), + ); + if (!applied) return null; + const row = expected.toRow(); + return new ThreadMember({ + ...row, + flags: patch.flags ?? row.flags, + muted: patch.muted ?? row.muted, + mute_config: patch.muteConfig !== undefined ? patch.muteConfig : row.mute_config, + }); + } + + async listThreadIdsByParent( + parentId: ChannelID, + opts: {after?: ChannelID; limit: number}, + ): Promise> { + const bounded = opts.after !== undefined; + const query = ThreadsByParent.select({ + where: bounded + ? [ThreadsByParent.where.eq('parent_id'), ThreadsByParent.where.gt('thread_id', 'after')] + : ThreadsByParent.where.eq('parent_id'), + limit: opts.limit, + }); + const rows = await fetchMany( + query.bind(bounded ? {parent_id: parentId, after: opts.after as ChannelID} : {parent_id: parentId}), + ); + return rows.map((row) => row.thread_id); + } + + async listParentThreads(parentId: ChannelID): Promise> { + const threads: Array<{threadId: ChannelID; type: number}> = []; + let after: ChannelID | null = null; + for (;;) { + const rows: Array = await fetchMany( + after === null + ? FETCH_THREADS_BY_PARENT_FIRST.bind({parent_id: parentId}) + : FETCH_THREADS_BY_PARENT_AFTER.bind({parent_id: parentId, after}), + ); + for (const row of rows) threads.push({threadId: row.thread_id, type: row.type}); + if (rows.length < ENUMERATION_PAGE_SIZE) break; + after = rows[rows.length - 1]!.thread_id; + } + return threads; + } + + async setThreadType(threadId: ChannelID, type: number): Promise { + for (let attempt = 0; attempt < STATE_CAS_ATTEMPTS; attempt++) { + const current = await this.getState(threadId); + if (!current) return null; + if (current.type !== type) { + const applied = await executeConditional( + ThreadStateTable.conditionalPatchByPk( + {thread_id: threadId}, + {type: Db.set(type), state_version: Db.set(current.stateVersion + 1)}, + {state_version: current.stateVersion}, + ), + ); + if (!applied) continue; + } + const next = new ThreadState({ + ...current.toRow(), + type, + state_version: current.type !== type ? current.stateVersion + 1 : current.stateVersion, + }); + const batch = new BatchBuilder(); + batch.addPrepared(Channels.patchByPk({channel_id: threadId, soft_deleted: false}, {type: Db.set(type)})); + batch.addPrepared( + ThreadsByParent.upsertAll({parent_id: next.parentId, thread_id: threadId, guild_id: next.guildId, type}), + ); + if (!next.archived) batch.addPrepared(ActiveThreadsByGuild.upsertAll(this.activeRow(next))); + await batch.execute(); + const channel = await fetchOne(FETCH_CHANNEL.bind({channel_id: threadId, soft_deleted: false})); + if (channel && channel.guild_id == null) { + await deleteOneOrMany(Channels.deleteByPk({channel_id: threadId, soft_deleted: false})); + } + const latest = await this.getState(threadId); + if (!latest) { + const cleanup = new BatchBuilder(); + cleanup.addPrepared(ActiveThreadsByGuild.deleteByPk({guild_id: next.guildId, thread_id: threadId})); + cleanup.addPrepared(ThreadsByParent.deleteByPk({parent_id: next.parentId, thread_id: threadId})); + await cleanup.execute(false); + return null; + } + if (!next.archived && latest.archived) { + await deleteOneOrMany(ActiveThreadsByGuild.deleteByPk({guild_id: next.guildId, thread_id: threadId})); + } + return next; + } + throw new ServiceUnavailableError(); + } + + async listGuildThreadIds( + guildId: GuildID, + opts?: {activeSince?: Date; parents?: ReadonlyArray<{id: ChannelID; type: number}>}, + ): Promise> { + if (!(await isTainted(guildId, {fresh: true}))) return []; + const parents = + opts?.parents ?? + (await this.channelData.listGuildChannels(guildId, 'maintenance')).map((channel) => ({ + id: channel.id, + type: channel.type, + })); + const threadIds: Array = []; + for (const parent of parents) { + if (!THREAD_PARENT_CHANNEL_TYPES.has(parent.type)) continue; + let after: ChannelID | null = null; + for (;;) { + const rows: Array = await fetchMany( + after === null + ? FETCH_THREADS_BY_PARENT_FIRST.bind({parent_id: parent.id}) + : FETCH_THREADS_BY_PARENT_AFTER.bind({parent_id: parent.id, after}), + ); + for (const row of rows) threadIds.push(row.thread_id); + if (rows.length < ENUMERATION_PAGE_SIZE) break; + after = rows[rows.length - 1]!.thread_id; + } + } + const activeSince = opts?.activeSince; + if (!activeSince || threadIds.length === 0) return threadIds; + const channels = await fetchManyInChunks>( + FETCH_CHANNELS, + threadIds, + (chunk) => ({channel_ids: chunk, soft_deleted: false}), + ); + const lastActivity = new Map(channels.map((row) => [row.channel_id, row.last_message_id ?? row.channel_id])); + const cutoff = activeSince.getTime(); + return threadIds.filter((id) => snowflakeToDate(lastActivity.get(id) ?? id).getTime() >= cutoff); + } + + async purgeThread(threadId: ChannelID): Promise { + const state = await this.getState(threadId); + const channel = await fetchOne(FETCH_CHANNEL.bind({channel_id: threadId, soft_deleted: false})); + const parentId = state?.parentId ?? channel?.parent_id ?? null; + await this.messages.deleteAllChannelMessages(threadId); + await this.purgeMembers(threadId, state?.type ?? channel?.type ?? ChannelTypes.PUBLIC_THREAD); + const guildId = state?.guildId ?? channel?.guild_id ?? null; + const side = new BatchBuilder(); + if (guildId !== null) { + side.addPrepared(ActiveThreadsByGuild.deleteByPk({guild_id: guildId, thread_id: threadId})); + } + if (state?.archived && state.archiveTimestamp) { + side.addPrepared(ArchivedThreadsByParent.deleteByPk(this.archivedKey(state, state.archiveTimestamp))); + } + side.addPrepared(ThreadStatsTable.deleteByPk({thread_id: threadId})); + await side.execute(false); + if (state?.isPinned) await this.releaseForumPin(state.parentId, threadId); + await this.channelData.delete(threadId, channel?.guild_id ?? state?.guildId, channel?.type ?? state?.type); + await deleteOneOrMany(ThreadStateTable.deleteByPk({thread_id: threadId})); + if (guildId !== null) { + await deleteOneOrMany(ActiveThreadsByGuild.deleteByPk({guild_id: guildId, thread_id: threadId})); + } + if (parentId !== null) { + await deleteOneOrMany(ThreadsByParent.deleteByPk({parent_id: parentId, thread_id: threadId})); + } + } + + async revertParentLastMessageId(parentId: ChannelID, threadId: ChannelID, previous: MessageID | null): Promise { + const parent = await fetchOne(FETCH_CHANNEL.bind({channel_id: parentId, soft_deleted: false})); + if (parent?.last_message_id !== channelIdToMessageId(threadId)) return; + await upsertOne( + Channels.patchByPk( + {channel_id: parentId, soft_deleted: false}, + {last_message_id: previous === null ? Db.clear() : Db.set(previous)}, + ), + ); + } + + private async purgeMembers(threadId: ChannelID, type: number): Promise { + let after: UserID | undefined; + for (;;) { + const page = await this.listMembers(threadId, {after, limit: MEMBER_PAGE_SIZE}); + if (page.length === 0) break; + const batch = new BatchBuilder(); + for (const member of page) { + batch.addPrepared( + ThreadMembersByUser.deleteByPk( + this.memberByUserRow( + {thread_id: threadId, guild_id: member.guildId, parent_id: member.parentId, type}, + member.userId, + ), + ), + ); + } + await batch.executeChunked(100); + if (page.length < MEMBER_PAGE_SIZE) break; + after = page[page.length - 1]!.userId; + } + await deleteOneOrMany(ThreadMembers.deletePartition({thread_id: threadId})); + } + + async purgeGuild(guildId: GuildID): Promise { + const batch = new BatchBuilder(); + batch.addPrepared(ThreadParentConfigTable.deletePartition({guild_id: guildId})); + batch.addPrepared(ThreadOnlyChannelsByGuild.deletePartition({guild_id: guildId})); + batch.addPrepared(ActiveThreadsByGuild.deletePartition({guild_id: guildId})); + batch.addPrepared(GuildThreadState.deleteByPk({guild_id: guildId})); + await batch.execute(false); + } + + async getGuildMarker(guildId: GuildID): Promise { + return fetchOne(FETCH_GUILD_MARKER.bind({guild_id: guildId})); + } + + async ensureGuildMarker(guildId: GuildID, opts?: {permsSeededAt?: Date}): Promise { + const created = await insertGuildThreadMarker(guildId, opts?.permsSeededAt ?? null); + if (!created && opts?.permsSeededAt) { + await this.markGuildPermsSeeded(guildId, opts.permsSeededAt); + } + } + + async markGuildPermsSeeded(guildId: GuildID, at: Date): Promise { + for (let attempt = 0; attempt < STATE_CAS_ATTEMPTS; attempt++) { + const marker = await this.getGuildMarker(guildId); + if (!marker) { + if (await insertGuildThreadMarker(guildId, at)) return; + continue; + } + if (marker.perms_seeded_at) return; + const patched = await executeConditional( + GuildThreadState.conditionalPatchByPk( + {guild_id: guildId}, + {perms_seeded_at: Db.set(at)}, + {first_active_at: marker.first_active_at}, + ), + ); + if (patched) return; + } + throw new ServiceUnavailableError(); + } + + async markGuildSearchBackfilled(guildId: GuildID, at: Date): Promise { + await this.patchGuildMarker(guildId, {search_backfilled_at: Db.set(at)}); + } + + async clearGuildSearchBackfilled(guildId: GuildID): Promise { + const marker = await this.getGuildMarker(guildId); + if (!marker?.search_backfilled_at) return; + await this.patchGuildMarker(guildId, {search_backfilled_at: Db.clear()}); + } + + private async patchGuildMarker(guildId: GuildID, patch: {search_backfilled_at: DbOp}): Promise { + for (let attempt = 0; attempt < STATE_CAS_ATTEMPTS; attempt++) { + const marker = await this.getGuildMarker(guildId); + if (!marker) { + await insertGuildThreadMarker(guildId, null); + continue; + } + const patched = await executeConditional( + GuildThreadState.conditionalPatchByPk({guild_id: guildId}, patch, {first_active_at: marker.first_active_at}), + ); + if (patched) return; + } + throw new ServiceUnavailableError(); + } + + async getParentConfig(guildId: GuildID, channelId: ChannelID): Promise { + const row = await fetchOne( + FETCH_PARENT_CONFIG.bind({guild_id: guildId, channel_id: channelId}), + ); + return row ? new ThreadParentConfig(row) : null; + } + + async listParentConfigs(guildId: GuildID): Promise> { + const rows = await fetchMany(FETCH_PARENT_CONFIGS.bind({guild_id: guildId})); + return rows.map((row) => new ThreadParentConfig(row)); + } + + async patchParentConfig(guildId: GuildID, channelId: ChannelID, patch: ThreadParentConfigPatch): Promise { + const ops: Record> = {}; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + const empty = value === null || (Array.isArray(value) && value.length === 0); + ops[key] = empty ? Db.clear() : Db.set(value); + } + if (Object.keys(ops).length === 0) return; + await upsertOne( + ThreadParentConfigTable.patchByPk( + {guild_id: guildId, channel_id: channelId}, + ops as Parameters[1], + ), + ); + } + + async deleteParentConfig(guildId: GuildID, channelId: ChannelID): Promise { + await deleteOneOrMany(ThreadParentConfigTable.deleteByPk({guild_id: guildId, channel_id: channelId})); + } +} diff --git a/fluxer_api/src/api/channel/services/AttachmentUploadService.ts b/fluxer_api/src/api/channel/services/AttachmentUploadService.ts index 41b0d832d..239b42904 100644 --- a/fluxer_api/src/api/channel/services/AttachmentUploadService.ts +++ b/fluxer_api/src/api/channel/services/AttachmentUploadService.ts @@ -22,6 +22,7 @@ import { } from '@app/api/channel/services/message/MessageHelpers'; import {applyUploadRelayDecision, resolveUploadRelayDecision} from '@app/api/channel/services/UploadRelay'; import {SYSTEM_USER_ID} from '@app/api/constants/Core'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {IStorageService} from '@app/api/infrastructure/IStorageService'; @@ -44,6 +45,7 @@ import { ATTACHMENT_UPLOAD_MAX_CHUNKS, resolveAttachmentUploadPartSize, } from '@fluxer/constants/src/LimitConstants'; +import {THREAD_FEATURE_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {CannotSendMessageToNonTextChannelError} from '@fluxer/errors/src/domains/channel/CannotSendMessageToNonTextChannelError'; import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; @@ -64,6 +66,7 @@ import type { interface DeleteAttachmentParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; attachmentId: AttachmentID; @@ -74,6 +77,7 @@ type UploadActor = 'member' | 'webhook'; interface UploadFormDataAttachmentsParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; clientIp: string; files: Array<{ @@ -89,6 +93,7 @@ interface UploadFormDataAttachmentsParams { interface RequestPresignedAttachmentUploadUrlsParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; clientIp: string; attachments: Array; @@ -96,6 +101,7 @@ interface RequestPresignedAttachmentUploadUrlsParams { interface CompleteMultipartAttachmentUploadsParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; clientIp: string; uploads: Array; @@ -118,13 +124,14 @@ export class AttachmentUploadService { async uploadFormDataAttachments({ userId, + viewer, channelId, clientIp, files, attachmentMetadata, actor = 'member', }: UploadFormDataAttachmentsParams): Promise> { - const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor}); + const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, viewer, channelId, actor}); assertAttachmentFileSizesWithinLimit( files.map(({file}) => file.size), maxFileSize, @@ -172,6 +179,7 @@ export class AttachmentUploadService { async requestPresignedAttachmentUploadUrls({ userId, + viewer, channelId, clientIp, attachments, @@ -179,7 +187,7 @@ export class AttachmentUploadService { if (!Config.presignedAttachmentUploadsEnabled) { throw new FeatureTemporarilyDisabledError(); } - const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor: 'member'}); + const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, viewer, channelId, actor: 'member'}); assertAttachmentFileSizesWithinLimit( attachments.map(({file_size}) => file_size), maxFileSize, @@ -279,6 +287,7 @@ export class AttachmentUploadService { async completeMultipartAttachmentUploads({ userId, + viewer, channelId, clientIp, uploads, @@ -286,7 +295,7 @@ export class AttachmentUploadService { if (!Config.presignedAttachmentUploadsEnabled) { throw new FeatureTemporarilyDisabledError(); } - const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor: 'member'}); + const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, viewer, channelId, actor: 'member'}); const bucket = Config.s3.buckets.uploads; return Promise.all( uploads.map(async ({upload_filename, upload_id}, index) => { @@ -349,6 +358,7 @@ export class AttachmentUploadService { async deleteAttachment({ userId, + viewer, channelId, messageId, attachmentId, @@ -357,6 +367,7 @@ export class AttachmentUploadService { const {channel, guild} = await this.messageInteractionService.authService.getChannelAuthenticated({ userId, channelId, + viewer, }); if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) { throw new FeatureTemporarilyDisabledError(); @@ -380,6 +391,7 @@ export class AttachmentUploadService { if (willBeEmpty) { await this.messageService.deletion.deleteMessage({ userId, + viewer, channelId, messageId, requestCache, @@ -411,6 +423,7 @@ export class AttachmentUploadService { if (!updatedMessage) { await this.messageService.deletion.deleteMessage({ userId, + viewer, channelId, messageId, requestCache, @@ -449,10 +462,12 @@ export class AttachmentUploadService { private async getUploadPermissionAndLimit({ userId, + viewer, channelId, actor, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; actor: UploadActor; }): Promise<{ @@ -461,8 +476,8 @@ export class AttachmentUploadService { const {channel, guild} = actor === 'webhook' ? await this.getWebhookUploadChannel(channelId) - : await this.getMemberUploadChannel({userId, channelId}); - if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) { + : await this.getMemberUploadChannel({userId, viewer, channelId}); + if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type) && !THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) { throw new CannotSendMessageToNonTextChannelError(); } const user = await this.userRepository.findUnique(userId); @@ -478,7 +493,15 @@ export class AttachmentUploadService { return {maxFileSize}; } - private async getMemberUploadChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<{ + private async getMemberUploadChannel({ + userId, + viewer, + channelId, + }: { + userId: UserID; + viewer: ThreadViewer; + channelId: ChannelID; + }): Promise<{ channel: Channel; guild: GuildResponse | null; }> { @@ -486,6 +509,7 @@ export class AttachmentUploadService { await this.messageInteractionService.authService.getChannelAuthenticated({ userId, channelId, + viewer, }); if (guild) { await checkPermission(Permissions.SEND_MESSAGES | Permissions.ATTACH_FILES); diff --git a/fluxer_api/src/api/channel/services/AuthenticatedChannel.ts b/fluxer_api/src/api/channel/services/AuthenticatedChannel.ts index 6e570a584..84f21442c 100644 --- a/fluxer_api/src/api/channel/services/AuthenticatedChannel.ts +++ b/fluxer_api/src/api/channel/services/AuthenticatedChannel.ts @@ -1,13 +1,27 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {Channel} from '@app/api/models/Channel'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import type {ThreadActorContext} from '@fluxer/constants/src/ThreadPermissionUtils'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; +export interface AuthenticatedThread { + state: ThreadState; + parent: Channel; + member: ThreadMember | null; + parentPermissions: bigint; + actor: ThreadActorContext; + isModerator: boolean; + enforceMfa: (permission: bigint) => void; +} + export interface AuthenticatedChannel { channel: Channel; guild: GuildResponse | null; member: GuildMemberResponse | null; hasPermission: (permission: bigint) => Promise; checkPermission: (permission: bigint) => Promise; + thread?: AuthenticatedThread; } diff --git a/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts b/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts index c8767269f..0c77a8f7c 100644 --- a/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts +++ b/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts @@ -2,12 +2,13 @@ import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; -import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; +import type {AuthenticatedChannel, AuthenticatedThread} from '@app/api/channel/services/AuthenticatedChannel'; import {DMPermissionValidator} from '@app/api/channel/services/DMPermissionValidator'; import { ensurePersonalNotesChannelExists, isPersonalNotesChannelId, } from '@app/api/channel/services/PersonalNotesChannelRepair'; +import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials'; import { type ContentWarningChannelLike, channelResponseToContentWarningView, @@ -16,6 +17,15 @@ import { guildResponseToContentWarningView, } from '@app/api/channel/utils/EffectiveContentWarning'; import {SYSTEM_USER_ID} from '@app/api/constants/Core'; +import { + THREAD_CHANNEL_TYPES, + THREAD_FEATURE_CHANNEL_TYPES, + THREAD_ONLY_CHANNEL_TYPES, + THREAD_PARENT_CHANNEL_TYPES, + type ThreadViewer, + viewerActive, +} from '@app/api/experiment/ChannelThreadsGate'; +import {isGuildMemberTimedOut} from '@app/api/guild/GuildModel'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement'; import type {GuildChannelAuthContext, IGatewayService} from '@app/api/infrastructure/IGatewayService'; @@ -25,6 +35,12 @@ import type {User} from '@app/api/models/User'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import { + canViewThread, + isThreadModerator, + threadViewPermissions, + withImplicitThreadBits, +} from '@fluxer/constants/src/ThreadPermissionUtils'; import {CannotSendMessagesToUserError} from '@fluxer/errors/src/domains/channel/CannotSendMessagesToUserError'; import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError'; @@ -33,6 +49,7 @@ import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildEr import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; +import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; export interface ChannelAuthOptions { errorOnMissingGuild: 'unknown_channel' | 'missing_permissions'; @@ -51,6 +68,13 @@ interface DMSendPermissionsByChannelIdParams { type DMSendPermissionsParams = DMSendPermissionsByChannelParams | DMSendPermissionsByChannelIdParams; +export interface ThreadPermissionContext { + guild: GuildResponse; + member: GuildMemberResponse; + parentCategory: GuildChannelAuthContext['parentChannel']; + thread: AuthenticatedThread; +} + export abstract class BaseChannelAuthService { protected abstract readonly options: ChannelAuthOptions; protected dmPermissionValidator: DMPermissionValidator; @@ -70,10 +94,12 @@ export abstract class BaseChannelAuthService { async getChannelAuthenticated({ userId, channelId, + viewer, skipNsfwValidation, }: { userId: UserID; channelId: ChannelID; + viewer: ThreadViewer; skipNsfwValidation?: boolean; }): Promise { if (this.isPersonalNotesChannel({userId, channelId})) { @@ -85,6 +111,12 @@ export abstract class BaseChannelAuthService { } const channel = await this.channelRepository.channelData.findUnique(channelId); if (!channel) throw new UnknownChannelError(); + if ( + THREAD_FEATURE_CHANNEL_TYPES.has(channel.type) && + (channel.guildId === null || !viewerActive(viewer, channel.guildId)) + ) { + throw new UnknownChannelError(); + } if (!channel.guildId) { const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds)); return this.getDMChannelAuth({channel, recipients, userId}); @@ -174,6 +206,9 @@ export abstract class BaseChannelAuthService { userId: UserID; skipNsfwValidation?: boolean; }): Promise { + if (THREAD_CHANNEL_TYPES.has(channel.type)) { + return this.getThreadChannelAuth({channel, userId, skipNsfwValidation}); + } const guildId = channel.guildId!; const [authContextResult, guildMemberResult] = await Promise.all([ this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(channel)}), @@ -226,7 +261,8 @@ export abstract class BaseChannelAuthService { (channel.type === ChannelTypes.GUILD_TEXT || channel.type === ChannelTypes.GUILD_ANNOUNCEMENT || channel.type === ChannelTypes.GUILD_VOICE || - channel.type === ChannelTypes.GUILD_LINK) && + channel.type === ChannelTypes.GUILD_LINK || + THREAD_ONLY_CHANNEL_TYPES.has(channel.type)) && requiresAgeVerification ) { const user = await this.userRepository.findUnique(userId); @@ -244,6 +280,111 @@ export abstract class BaseChannelAuthService { }; } + async resolveThreadPermissionContext({ + thread, + userId, + }: { + thread: Channel; + userId: UserID; + }): Promise { + const guildId = thread.guildId; + const parentId = thread.parentId; + if (guildId === null || parentId === null) throw new UnknownChannelError(); + const [parent, state, threadMember, guildMemberResult] = await Promise.all([ + this.channelRepository.channelData.findUnique(parentId), + this.channelRepository.threads.getState(thread.id), + this.channelRepository.threads.getMember(thread.id, userId), + this.fetchGuildMemberOrThrow({guildId, userId}), + ]); + if (!parent || !state || parent.guildId !== guildId || !THREAD_PARENT_CHANNEL_TYPES.has(parent.type)) { + throw new UnknownChannelError(); + } + if (!guildMemberResult.success || !guildMemberResult.memberData) { + this.throwGuildAccessError(); + } + const [authContextResult, parentPermissions] = await Promise.all([ + this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(parent)}), + this.gatewayService.getUserPermissions({guildId, userId, channelId: parent.id}), + ]); + if (!authContextResult) { + this.throwGuildAccessError(); + } + const member = await this.fillMissingMemberTimeout({guildId, userId, memberData: guildMemberResult.memberData}); + const guild = authContextResult.guild; + const enforceMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData: guild, userId}); + const isOwner = guild.owner_id === userId.toString(); + const timedOut = isGuildMemberTimedOut(member); + const actor = { + permissions: parentPermissions, + isOwner, + timedOut, + thread: { + type: state.type, + archived: state.archived, + locked: state.locked, + invitable: state.invitable ?? true, + }, + isThreadOwner: thread.ownerId === userId, + isMember: threadMember !== null, + }; + return { + guild, + member, + parentCategory: authContextResult.parentChannel, + thread: { + state, + parent, + member: threadMember, + parentPermissions, + actor, + isModerator: isThreadModerator(withImplicitThreadBits(parentPermissions), {isOwner, timedOut}), + enforceMfa, + }, + }; + } + + protected async getThreadChannelAuth({ + channel, + userId, + skipNsfwValidation, + }: { + channel: Channel; + userId: UserID; + skipNsfwValidation?: boolean; + }): Promise { + const context = await this.resolveThreadPermissionContext({thread: channel, userId}); + const {guild, member, thread} = context; + assertThreadAllowed(canViewThread(thread.actor)); + const permissions = threadViewPermissions(thread.parentPermissions); + const hasPermission = async (permission: bigint): Promise => { + const allowed = (permissions & permission) === permission; + if (allowed) thread.enforceMfa(permission); + return allowed; + }; + const checkPermission = async (permission: bigint): Promise => { + if (!(await hasPermission(permission))) throw new MissingPermissionsError(); + }; + if (this.options.validateNsfw && !skipNsfwValidation && THREAD_PARENT_CHANNEL_TYPES.has(thread.parent.type)) { + const parentCategory = await this.getParentCategoryContentWarningView({ + channel: thread.parent, + parentChannel: context.parentCategory, + }); + const requiresAgeVerification = computeEffectiveChannelNsfw( + channelToContentWarningView(thread.parent), + parentCategory, + guildResponseToContentWarningView(guild), + ); + if (requiresAgeVerification) { + const user = await this.userRepository.findUnique(userId); + if (!user) throw new UnknownUserError(); + if (!canUserAccessNsfwContent(user)) { + throw new NsfwContentRequiresAgeVerificationError(); + } + } + } + return {channel, guild, member, hasPermission, checkPermission, thread}; + } + private parentLookupChannelId(channel: Channel): ChannelID | undefined { if (!channel.parentId || channel.type === ChannelTypes.GUILD_CATEGORY) { return undefined; diff --git a/fluxer_api/src/api/channel/services/ChannelDataService.ts b/fluxer_api/src/api/channel/services/ChannelDataService.ts index 93dfacb7c..7d84b10db 100644 --- a/fluxer_api/src/api/channel/services/ChannelDataService.ts +++ b/fluxer_api/src/api/channel/services/ChannelDataService.ts @@ -12,6 +12,9 @@ import {ChannelOperationsService} from '@app/api/channel/services/channel_data/C import {ChannelUtilsService} from '@app/api/channel/services/channel_data/ChannelUtilsService'; import {GroupDmUpdateService} from '@app/api/channel/services/channel_data/GroupDmUpdateService'; import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService'; +import {ThreadModifyService} from '@app/api/channel/services/thread/ThreadModifyService'; +import {pickThreadParentSettings} from '@app/api/channel/services/thread/ThreadParentSettings'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import type {AvatarService} from '@app/api/infrastructure/AvatarService'; @@ -30,12 +33,16 @@ import type {IUserRepository} from '@app/api/user/IUserRepository'; import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService'; import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; -import type {ChannelUpdateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas'; +import type { + ChannelUpdateGatedRequest, + ChannelUpdateNonThreadRequest, + ChannelUpdateThreadRequest, +} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas'; import type {ICacheService} from '@pkgs/cache/src/ICacheService'; import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService'; type GuildChannelUpdateRequest = Exclude< - ChannelUpdateRequest, + ChannelUpdateNonThreadRequest, { type: typeof ChannelTypes.GROUP_DM; } @@ -47,6 +54,7 @@ export class ChannelDataService { public readonly operations: ChannelOperationsService; public readonly groupDmUpdate: GroupDmUpdateService; public readonly utils: ChannelUtilsService; + public readonly threadModify: ThreadModifyService; constructor( channelRepository: IChannelRepositoryAggregate, @@ -93,7 +101,18 @@ export class ChannelDataService { limitConfigService, rateLimitService, cacheService, + snowflakeService, ); + this.threadModify = new ThreadModifyService({ + channelRepository, + gatewayService, + guildAuditLogService, + rateLimitService, + cacheService, + snowflakeService, + messagePersistence: messagePersistenceService, + utils: this.utils, + }); this.groupDmUpdate = new GroupDmUpdateService( channelRepository, userRepository, @@ -104,8 +123,26 @@ export class ChannelDataService { ); } + async deleteThread({ + userId, + viewer, + channelId, + requestCache, + auditLogReason, + }: { + userId: UserID; + viewer: ThreadViewer; + channelId: ChannelID; + requestCache: RequestCache; + auditLogReason: string | null; + }): Promise { + const authChannel = await this.auth.getChannelAuthenticated({userId, channelId, viewer, skipNsfwValidation: true}); + await this.threadModify.deleteThread({authChannel, userId, requestCache, auditLogReason}); + } + async editChannel({ userId, + viewer, channelId, data, clientFeatures, @@ -114,22 +151,34 @@ export class ChannelDataService { typeConversion, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; - data: Omit; + data: Omit; clientFeatures: ReadonlySet; requestCache: RequestCache; auditLogReason: string | null; typeConversion?: ChannelTypeConversion | null; }): Promise { - const {channel} = await this.auth.getChannelAuthenticated({userId, channelId, skipNsfwValidation: true}); + const authChannel = await this.auth.getChannelAuthenticated({userId, channelId, viewer, skipNsfwValidation: true}); + const {channel} = authChannel; + if (authChannel.thread) { + return this.threadModify.updateThread({ + authChannel, + userId, + data: data as Omit, + requestCache, + auditLogReason, + }); + } if (channel.type === ChannelTypes.GROUP_DM) { + const groupDmData = data as Omit, 'type'>; return await this.groupDmUpdate.updateGroupDmChannel({ userId, channelId, - name: data.name !== undefined ? data.name : undefined, - icon: data.icon !== undefined ? data.icon : undefined, - ownerId: data.owner_id ? createUserID(data.owner_id) : undefined, - nicks: data.nicks, + name: groupDmData.name !== undefined ? groupDmData.name : undefined, + icon: groupDmData.icon !== undefined ? groupDmData.icon : undefined, + ownerId: groupDmData.owner_id ? createUserID(groupDmData.owner_id) : undefined, + nicks: groupDmData.nicks, requestCache, }); } @@ -188,8 +237,10 @@ export class ChannelDataService { } return this.operations.editChannel({ userId, + viewer, channelId, data: channelUpdateData, + threadParent: pickThreadParentSettings(channel.type, guildChannelData), clientFeatures, requestCache, auditLogReason, diff --git a/fluxer_api/src/api/channel/services/ChannelFollowers.ts b/fluxer_api/src/api/channel/services/ChannelFollowers.ts index 9b3d41f44..2770ab916 100644 --- a/fluxer_api/src/api/channel/services/ChannelFollowers.ts +++ b/fluxer_api/src/api/channel/services/ChannelFollowers.ts @@ -21,11 +21,13 @@ export async function withChannelFollowLock( cacheService: ICacheService, channelId: ChannelID, fn: () => Promise, + keepAliveTtlSeconds?: number, ): Promise { + const ttlSeconds = keepAliveTtlSeconds ?? CHANNEL_FOLLOW_LOCK_TTL_SECONDS; const lockKey = `channel-follow:${channelId}`; let lockToken: string | null = null; for (let attempt = 0; attempt < CHANNEL_FOLLOW_LOCK_ACQUIRE_ATTEMPTS; attempt++) { - lockToken = await cacheService.acquireLock(lockKey, CHANNEL_FOLLOW_LOCK_TTL_SECONDS); + lockToken = await cacheService.acquireLock(lockKey, ttlSeconds); if (lockToken) break; await new Promise((resolve) => setTimeout(resolve, CHANNEL_FOLLOW_LOCK_RETRY_DELAY_MS * (attempt + 1))); } @@ -36,10 +38,21 @@ export async function withChannelFollowLock( data: {retry_after: 1}, }); } + const token = lockToken; + const keepAlive = + keepAliveTtlSeconds === undefined + ? null + : setInterval( + () => { + cacheService.extendLock(lockKey, token, keepAliveTtlSeconds).catch(() => {}); + }, + (keepAliveTtlSeconds * 1000) / 3, + ); try { return await fn(); } finally { - await cacheService.releaseLock(lockKey, lockToken).catch(() => {}); + if (keepAlive) clearInterval(keepAlive); + await cacheService.releaseLock(lockKey, token).catch(() => {}); } } diff --git a/fluxer_api/src/api/channel/services/ChannelGatewayDispatch.ts b/fluxer_api/src/api/channel/services/ChannelGatewayDispatch.ts index 9de07a652..f02c2177e 100644 --- a/fluxer_api/src/api/channel/services/ChannelGatewayDispatch.ts +++ b/fluxer_api/src/api/channel/services/ChannelGatewayDispatch.ts @@ -6,6 +6,7 @@ import type {GatewayDispatchEvent} from '@app/api/constants/Gateway'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {Channel} from '@app/api/models/Channel'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; interface DispatchChannelEventParams { gatewayService: IGatewayService; @@ -14,6 +15,14 @@ interface DispatchChannelEventParams { data: unknown; } +export function withThreadContext(channel: Channel, data: unknown): unknown { + if (!THREAD_CHANNEL_TYPES.has(channel.type) || typeof data !== 'object' || data === null) return data; + return { + ...data, + _fluxer_thread: {id: channel.id.toString(), parent_id: channel.parentId?.toString() ?? null, type: channel.type}, + }; +} + export async function dispatchChannelEvent({ gatewayService, channel, @@ -32,7 +41,7 @@ export async function dispatchChannelEvent({ }); } if (channel.guildId) { - return gatewayService.dispatchGuild({guildId: channel.guildId, event, data}); + return gatewayService.dispatchGuild({guildId: channel.guildId, event, data: withThreadContext(channel, data)}); } await Promise.all( Array.from(channel.recipientIds) diff --git a/fluxer_api/src/api/channel/services/ChannelRequestService.ts b/fluxer_api/src/api/channel/services/ChannelRequestService.ts index 72140d42f..050d04d68 100644 --- a/fluxer_api/src/api/channel/services/ChannelRequestService.ts +++ b/fluxer_api/src/api/channel/services/ChannelRequestService.ts @@ -4,38 +4,94 @@ import type {ChannelID, UserID} from '@app/api/BrandedTypes'; import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; import type {ChannelTypeConversion} from '@app/api/channel/services/channel_data/ChannelOperationsService'; +import {applyForumTagEdit, type ForumTagEdit} from '@app/api/channel/services/thread/ForumTagService'; +import {mapThreadToResponse} from '@app/api/channel/services/thread/ThreadMappers'; +import {withThreadParentFields} from '@app/api/channel/services/thread/ThreadParentSettings'; +import {loadThreadViews} from '@app/api/channel/services/thread/ThreadViews'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; +import {maskChannelResponseThreadBits} from '@app/api/guild/services/ThreadPermissionBits'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import type {Channel} from '@app/api/models/Channel'; import type {User} from '@app/api/models/User'; -import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; -import type {ChannelUpdateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; +import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; +import {ThrottledError} from '@fluxer/errors/src/domains/core/ThrottledError'; +import type {ChannelUpdateGatedRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas'; import type {ChannelResponse, ChannelSlowmodeStateResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type {ICacheService} from '@pkgs/cache/src/ICacheService'; + +const FORUM_TAGS_LOCK_TTL_SECONDS = 5; +const FORUM_TAGS_LOCK_ACQUIRE_ATTEMPTS = 6; +const FORUM_TAGS_LOCK_RETRY_DELAY_MS = 50; export class ChannelRequestService { constructor( private readonly channelService: ChannelService, private readonly userCacheService: UserCacheService, + private readonly cacheService: ICacheService, ) {} async getChannelResponse(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; requestCache: RequestCache; }): Promise { const channel = await this.channelService.channelData.operations.getChannel({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, }); - return mapChannelToResponse({ + return this.maskedChannelResponse(channel, params); + } + + private async maskedChannelResponse( + channel: Channel, + params: {userId: UserID; viewer: ThreadViewer; requestCache: RequestCache}, + ): Promise { + if (channel.isThread()) return this.threadResponse(channel, params.userId); + const response = await mapChannelToResponse({ channel, currentUserId: params.userId, userCacheService: this.userCacheService, requestCache: params.requestCache, }); + const [masked] = await maskChannelResponseThreadBits(channel.guildId, params.viewer, [ + await withThreadParentFields( + this.channelService.channelData.threadModify.repository.threads, + channel, + response, + params.viewer, + ), + ]); + return masked; } - async getSlowmodeState(params: {user: User; channelId: ChannelID}): Promise { - const state = await this.channelService.getSlowmodeState({user: params.user, channelId: params.channelId}); + private async threadResponse(channel: Channel, userId: UserID): Promise { + const repository = this.channelService.channelData.threadModify.repository; + const [state, member] = await Promise.all([ + repository.threads.getState(channel.id), + repository.threads.getMember(channel.id, userId), + ]); + if (!state) throw new UnknownChannelError(); + const [view] = await loadThreadViews(repository, [state]); + if (!view) throw new UnknownChannelError(); + return mapThreadToResponse({...view, channel}, member); + } + + async getSlowmodeState(params: { + user: User; + viewer: ThreadViewer; + channelId: ChannelID; + }): Promise { + const state = await this.channelService.getSlowmodeState({ + user: params.user, + viewer: params.viewer, + channelId: params.channelId, + }); return { rate_limit_per_user: state.rateLimitPerUser, retry_after_ms: state.retryAfterMs, @@ -44,9 +100,10 @@ export class ChannelRequestService { }; } - async listRtcRegions(params: {userId: UserID; channelId: ChannelID}) { + async listRtcRegions(params: {userId: UserID; viewer: ThreadViewer; channelId: ChannelID}) { const regions = await this.channelService.channelData.operations.getAvailableRtcRegions({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, }); return regions.map((region) => ({ @@ -58,8 +115,25 @@ export class ChannelRequestService { async updateChannel(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; - data: ChannelUpdateRequest; + data: ChannelUpdateGatedRequest; + clientFeatures: ReadonlySet; + requestCache: RequestCache; + auditLogReason: string | null; + typeConversion?: ChannelTypeConversion | null; + }): Promise { + if (!('available_tags' in params.data) || params.data.available_tags === undefined) { + return this.applyChannelUpdate(params); + } + return this.withForumTagsLock(params.channelId, () => this.applyChannelUpdate(params)); + } + + private async applyChannelUpdate(params: { + userId: UserID; + viewer: ThreadViewer; + channelId: ChannelID; + data: ChannelUpdateGatedRequest; clientFeatures: ReadonlySet; requestCache: RequestCache; auditLogReason: string | null; @@ -67,6 +141,7 @@ export class ChannelRequestService { }): Promise { const channel = await this.channelService.channelData.editChannel({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, data: params.data, clientFeatures: params.clientFeatures, @@ -74,16 +149,69 @@ export class ChannelRequestService { auditLogReason: params.auditLogReason, typeConversion: params.typeConversion, }); - return mapChannelToResponse({ - channel, - currentUserId: params.userId, - userCacheService: this.userCacheService, - requestCache: params.requestCache, + return this.maskedChannelResponse(channel, params); + } + + async editForumTags(params: { + userId: UserID; + viewer: ThreadViewer; + channelId: ChannelID; + edit: ForumTagEdit; + clientFeatures: ReadonlySet; + requestCache: RequestCache; + auditLogReason: string | null; + }): Promise { + const {channel, checkPermission} = await this.channelService.channelData.auth.getChannelAuthenticated({ + userId: params.userId, + channelId: params.channelId, + viewer: params.viewer, + skipNsfwValidation: true, }); + if (!channel.isThreadOnly() || channel.guildId === null) throw new InvalidChannelTypeError(); + await checkPermission(Permissions.MANAGE_CHANNELS); + const guildId = channel.guildId; + return this.withForumTagsLock(channel.id, async () => { + const config = await this.channelService.channelData.threadModify.repository.threads.getParentConfig( + guildId, + channel.id, + ); + return this.applyChannelUpdate({ + userId: params.userId, + viewer: params.viewer, + channelId: params.channelId, + data: {type: channel.type, available_tags: applyForumTagEdit(config, params.edit)} as ChannelUpdateGatedRequest, + clientFeatures: params.clientFeatures, + requestCache: params.requestCache, + auditLogReason: params.auditLogReason, + }); + }); + } + + private async withForumTagsLock(channelId: ChannelID, fn: () => Promise): Promise { + const lockKey = `channel:${channelId}:forum-tags`; + let lockToken: string | null = null; + for (let attempt = 0; attempt < FORUM_TAGS_LOCK_ACQUIRE_ATTEMPTS; attempt++) { + lockToken = await this.cacheService.acquireLock(lockKey, FORUM_TAGS_LOCK_TTL_SECONDS); + if (lockToken) break; + await new Promise((resolve) => setTimeout(resolve, FORUM_TAGS_LOCK_RETRY_DELAY_MS * (attempt + 1))); + } + if (!lockToken) { + throw new ThrottledError({ + code: APIErrorCodes.RESOURCE_LOCKED, + retryAfterSeconds: 1, + data: {retry_after: 1}, + }); + } + try { + return await fn(); + } finally { + await this.cacheService.releaseLock(lockKey, lockToken).catch(() => {}); + } } async deleteChannel(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; requestCache: RequestCache; silent?: boolean; @@ -91,8 +219,19 @@ export class ChannelRequestService { }): Promise { const channel = await this.channelService.channelData.operations.getChannel({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, }); + if (channel.isThread()) { + await this.channelService.channelData.deleteThread({ + userId: params.userId, + viewer: params.viewer, + channelId: params.channelId, + requestCache: params.requestCache, + auditLogReason: params.auditLogReason, + }); + return; + } if (channel.type === ChannelTypes.GROUP_DM) { await this.channelService.groupDms.removeRecipientFromChannel({ userId: params.userId, @@ -105,6 +244,7 @@ export class ChannelRequestService { } await this.channelService.channelData.operations.deleteChannel({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, requestCache: params.requestCache, auditLogReason: params.auditLogReason, diff --git a/fluxer_api/src/api/channel/services/ChannelService.ts b/fluxer_api/src/api/channel/services/ChannelService.ts index c7cd981c5..315bd7f18 100644 --- a/fluxer_api/src/api/channel/services/ChannelService.ts +++ b/fluxer_api/src/api/channel/services/ChannelService.ts @@ -5,6 +5,7 @@ import type {ChannelID} from '@app/api/BrandedTypes'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import type {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository'; import {AttachmentUploadService} from '@app/api/channel/services/AttachmentUploadService'; +import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; import {CallService} from '@app/api/channel/services/CallService'; import {ChannelDataService} from '@app/api/channel/services/ChannelDataService'; import {GroupDmOperationsService} from '@app/api/channel/services/group_dm/GroupDmOperationsService'; @@ -12,6 +13,7 @@ import {MessageInteractionService} from '@app/api/channel/services/MessageIntera import {MessageService} from '@app/api/channel/services/MessageService'; import {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService'; import {UserMessageDeletionService} from '@app/api/channel/services/message/UserMessageDeletionService'; +import {type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate'; import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; @@ -30,9 +32,15 @@ import type {IUserRepository} from '@app/api/user/IUserRepository'; import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService'; import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {TEXT_THREAD_PARENT_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService'; import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService'; +interface TypingCooldown { + message_send_cooldown_ms?: number; + thread_create_cooldown_ms?: number; +} + interface SlowmodeState { rateLimitPerUser: number; retryAfterMs: number; @@ -188,8 +196,51 @@ export class ChannelService { ); } - async getSlowmodeState({user, channelId}: {user: User; channelId: ChannelID}): Promise { - const auth = await this.channelData.auth.getChannelAuthenticated({userId: user.id, channelId}); + async getTypingCooldown({ + user, + viewer, + auth, + }: { + user: User; + viewer: ThreadViewer; + auth: AuthenticatedChannel; + }): Promise { + const {channel, guild} = auth; + if (!guild || user.isBot || !viewerActive(viewer, guild.id)) return null; + const createsThreads = TEXT_THREAD_PARENT_CHANNEL_TYPES.has(channel.type); + if (channel.rateLimitPerUser <= 0 || (await auth.hasPermission(Permissions.BYPASS_SLOWMODE))) return null; + const windowMs = channel.rateLimitPerUser * 1000; + const [messageSendCooldownMs, threadCreateCooldownMs] = await Promise.all([ + this.peekSlowmode(`slowmode:${channel.id}:${user.id}`, windowMs), + createsThreads ? this.peekSlowmode(`slowmode-thread:${channel.id}:${user.id}`, windowMs) : Promise.resolve(0), + ]); + if (messageSendCooldownMs <= 0 && threadCreateCooldownMs <= 0) return null; + return { + ...(messageSendCooldownMs > 0 ? {message_send_cooldown_ms: messageSendCooldownMs} : {}), + ...(threadCreateCooldownMs > 0 ? {thread_create_cooldown_ms: threadCreateCooldownMs} : {}), + }; + } + + private async peekSlowmode(identifier: string, windowMs: number): Promise { + const peek = await this.rateLimitService.peekLimit({ + identifier, + maxAttempts: 1, + windowMs, + algorithm: 'leaky_bucket', + }); + return Math.max(0, peek.resetTime.getTime() - Date.now()); + } + + async getSlowmodeState({ + user, + channelId, + viewer, + }: { + user: User; + viewer: ThreadViewer; + channelId: ChannelID; + }): Promise { + const auth = await this.channelData.auth.getChannelAuthenticated({userId: user.id, channelId, viewer}); const rateLimitPerUser = auth.channel.rateLimitPerUser ?? 0; if (!auth.guild || rateLimitPerUser <= 0 || user.isBot) { return {rateLimitPerUser, retryAfterMs: 0, nextSendAllowedAt: null, canBypass: false}; @@ -198,8 +249,9 @@ export class ChannelService { if (canBypass) { return {rateLimitPerUser, retryAfterMs: 0, nextSendAllowedAt: null, canBypass: true}; } + const bucket = auth.channel.isThreadOnly() ? 'slowmode-thread' : 'slowmode'; const peek = await this.rateLimitService.peekLimit({ - identifier: `slowmode:${channelId}:${user.id}`, + identifier: `${bucket}:${channelId}:${user.id}`, maxAttempts: 1, windowMs: rateLimitPerUser * 1000, algorithm: 'leaky_bucket', diff --git a/fluxer_api/src/api/channel/services/MessageInteractionService.ts b/fluxer_api/src/api/channel/services/MessageInteractionService.ts index 1655f60e0..8319b2177 100644 --- a/fluxer_api/src/api/channel/services/MessageInteractionService.ts +++ b/fluxer_api/src/api/channel/services/MessageInteractionService.ts @@ -2,6 +2,7 @@ import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; +import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; import {MessageInteractionAuthService} from '@app/api/channel/services/interaction/MessageInteractionAuthService'; import {MessagePinAuthService} from '@app/api/channel/services/interaction/MessagePinAuthService'; import {MessagePinService} from '@app/api/channel/services/interaction/MessagePinService'; @@ -9,6 +10,9 @@ import {MessageReactionService} from '@app/api/channel/services/interaction/Mess import {MessageReadStateService} from '@app/api/channel/services/interaction/MessageReadStateService'; import {dispatchMessageUpdateBroadcast} from '@app/api/channel/services/message/MessageGatewayDispatch'; import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService'; +import {maskThreadArtifactsFor} from '@app/api/channel/services/message/ThreadMessageResponses'; +import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; @@ -26,8 +30,10 @@ import { } from '@app/api/user/NewConversationLimit'; import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {threadWriteBlock} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; import {NewConversationsLimitedError} from '@fluxer/errors/src/domains/user/NewConversationsLimitedError'; -import type {ChannelPinResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import type {ChannelPinResponse, MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; export class MessageInteractionService { @@ -71,22 +77,37 @@ export class MessageInteractionService { ); } - async startTyping({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise { - const authChannel = await this.authService.getChannelAuthenticated({userId, channelId}); + async startTyping({ + userId, + channelId, + viewer, + }: { + userId: UserID; + viewer: ThreadViewer; + channelId: ChannelID; + }): Promise { + const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer}); + if (authChannel.channel.isThreadOnly()) throw new InvalidChannelTypeError(); await authChannel.checkPermission(Permissions.SEND_MESSAGES); assertGuildMemberCanCommunicate(authChannel.member); - if (!authChannel.guild && (await this.startsNewConversation(authChannel.channel, userId))) return; + if (authChannel.thread) { + assertThreadAllowed(threadWriteBlock('send', authChannel.thread.actor)); + } + if (!authChannel.guild && (await this.startsNewConversation(authChannel.channel, userId))) return authChannel; await this.readStateService.startTyping({authChannel, userId}); + return authChannel; } async getChannelPins({ userId, + viewer, channelId, requestCache, beforeTimestamp, limit, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; requestCache: RequestCache; beforeTimestamp?: Date; @@ -95,24 +116,37 @@ export class MessageInteractionService { items: Array; has_more: boolean; }> { - const authChannel = await this.pinAuthService.getChannelAuthenticated({userId, channelId}); - return this.pinService.getChannelPins({authChannel, userId, requestCache, beforeTimestamp, limit}); + const authChannel = await this.pinAuthService.getChannelAuthenticated({userId, channelId, viewer}); + const pins = await this.pinService.getChannelPins({authChannel, userId, requestCache, beforeTimestamp, limit}); + const original = pins.items.map((item) => item.message as MessageResponse); + const messages = maskThreadArtifactsFor(viewer, authChannel.channel.guildId, original); + if (messages === original) return pins; + const byId = new Map(messages.map((message) => [message.id, message])); + return { + ...pins, + items: pins.items.flatMap((item) => { + const message = byId.get(item.message.id); + return message ? [{...item, message}] : []; + }), + }; } async pinMessage({ userId, + viewer, channelId, messageId, requestCache, auditLogReason, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; requestCache: RequestCache; auditLogReason?: string | null; }): Promise { - const authChannel = await this.authService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer}); if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) { await this.authService.validateDMSendPermissions({channel: authChannel.channel, userId}); await this.assertConversationAllowed(authChannel.channel, userId); @@ -122,18 +156,20 @@ export class MessageInteractionService { async unpinMessage({ userId, + viewer, channelId, messageId, requestCache, auditLogReason, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; requestCache: RequestCache; auditLogReason?: string | null; }): Promise { - const authChannel = await this.authService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer}); if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) { await this.authService.validateDMSendPermissions({channel: authChannel.channel, userId}); } @@ -142,6 +178,7 @@ export class MessageInteractionService { async getUsersForReaction({ userId, + viewer, channelId, messageId, emoji, @@ -149,6 +186,7 @@ export class MessageInteractionService { after, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; emoji: string; @@ -159,25 +197,27 @@ export class MessageInteractionService { has_more: boolean; next_after: string | null; }> { - const authChannel = await this.authService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer}); return this.reactionService.getUsersForReaction({authChannel, messageId, emoji, limit, after, userId}); } async addReaction({ userId, + viewer, sessionId, channelId, messageId, emoji, }: { userId: UserID; + viewer: ThreadViewer; sessionId?: string; channelId: ChannelID; messageId: MessageID; emoji: string; requestCache: RequestCache; }): Promise { - const authChannel = await this.authService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer}); if (!authChannel.guild) { await this.assertConversationAllowed(authChannel.channel, userId); } @@ -210,6 +250,7 @@ export class MessageInteractionService { async removeReaction({ userId, + viewer, sessionId, channelId, messageId, @@ -217,6 +258,7 @@ export class MessageInteractionService { targetId, }: { userId: UserID; + viewer: ThreadViewer; sessionId?: string; channelId: ChannelID; messageId: MessageID; @@ -224,12 +266,13 @@ export class MessageInteractionService { targetId: UserID; requestCache: RequestCache; }): Promise { - const authChannel = await this.authService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer}); await this.reactionService.removeReaction({authChannel, messageId, emoji, targetId, sessionId, actorId: userId}); } async removeOwnReaction({ userId, + viewer, sessionId, channelId, messageId, @@ -237,53 +280,60 @@ export class MessageInteractionService { requestCache, }: { userId: UserID; + viewer: ThreadViewer; sessionId?: string; channelId: ChannelID; messageId: MessageID; emoji: string; requestCache: RequestCache; }): Promise { - await this.removeReaction({userId, sessionId, channelId, messageId, emoji, targetId: userId, requestCache}); + await this.removeReaction({userId, viewer, sessionId, channelId, messageId, emoji, targetId: userId, requestCache}); } async removeAllReactionsForEmoji({ userId, + viewer, channelId, messageId, emoji, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; emoji: string; }): Promise { - const authChannel = await this.authService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer}); await this.reactionService.removeAllReactionsForEmoji({authChannel, messageId, emoji}); } async removeAllReactions({ userId, + viewer, channelId, messageId, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; }): Promise { - const authChannel = await this.authService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer}); await this.reactionService.removeAllReactions({authChannel, messageId}); } async getMessageReactions({ userId, + viewer, channelId, messageId, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; }): Promise> { - const authChannel = await this.authService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer}); return this.reactionService.getMessageReactions({authChannel, messageId}); } diff --git a/fluxer_api/src/api/channel/services/MessageService.ts b/fluxer_api/src/api/channel/services/MessageService.ts index a8bc7d48e..acd684b3d 100644 --- a/fluxer_api/src/api/channel/services/MessageService.ts +++ b/fluxer_api/src/api/channel/services/MessageService.ts @@ -20,6 +20,7 @@ import {MessageSendService} from '@app/api/channel/services/message/MessageSendS import {MessageSystemService} from '@app/api/channel/services/message/MessageSystemService'; import {MessageValidationService} from '@app/api/channel/services/message/MessageValidationService'; import {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock'; +import {ThreadMessageActivity} from '@app/api/channel/services/message/ThreadMessageActivity'; import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; @@ -54,6 +55,7 @@ export class MessageService { public readonly writeLock: MessageWriteLock; public readonly crosspostPropagation: CrosspostPropagation; public readonly crosspost: MessageCrosspostService; + public readonly threadActivity: ThreadMessageActivity; constructor( channelRepository: IChannelRepositoryAggregate, @@ -117,7 +119,9 @@ export class MessageService { snowflakeService, favoriteMemeRepository, }); + this.threadActivity = new ThreadMessageActivity(channelRepository, gatewayService, userRepository); this.send = new MessageSendService({ + threadActivity: this.threadActivity, channelRepository, userRepository, storageService, diff --git a/fluxer_api/src/api/channel/services/StreamService.test.ts b/fluxer_api/src/api/channel/services/StreamService.test.ts index cecaf328c..b1fbcf724 100644 --- a/fluxer_api/src/api/channel/services/StreamService.test.ts +++ b/fluxer_api/src/api/channel/services/StreamService.test.ts @@ -4,6 +4,7 @@ import {createChannelID, createUserID} from '@app/api/BrandedTypes'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; import type {StreamPreviewService} from '@app/api/channel/services/StreamPreviewService'; import {StreamService} from '@app/api/channel/services/StreamService'; +import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {InvalidStreamThumbnailPayloadError} from '@fluxer/errors/src/domains/channel/InvalidStreamThumbnailPayloadError'; @@ -55,6 +56,7 @@ describe('StreamService.uploadPreview', () => { const upload = (thumbnail: string) => streamService.uploadPreview({ + viewer: SYSTEM_THREAD_VIEWER, userId: USER_ID, streamKey: STREAM_KEY, channelId: CHANNEL_ID, diff --git a/fluxer_api/src/api/channel/services/StreamService.ts b/fluxer_api/src/api/channel/services/StreamService.ts index c865ecb98..4b261c9ee 100644 --- a/fluxer_api/src/api/channel/services/StreamService.ts +++ b/fluxer_api/src/api/channel/services/StreamService.ts @@ -3,6 +3,7 @@ import {type ChannelID, createChannelID, createGuildID, type GuildID, type UserID} from '@app/api/BrandedTypes'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; import type {StreamPreviewService} from '@app/api/channel/services/StreamPreviewService'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; @@ -77,11 +78,13 @@ export class StreamService { private async assertStreamChannelAccess(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; parsedKey: ParsedStreamKey; }): Promise { const channel = await this.channelService.channelData.operations.getChannel({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, }); if (channel.guildId) { @@ -118,6 +121,7 @@ export class StreamService { private async assertStreamMutationAccess(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; parsedKey: ParsedStreamKey; }): Promise { @@ -149,11 +153,17 @@ export class StreamService { } } - async updateStreamRegion(params: {userId: UserID; streamKey: string; region?: string}): Promise { + async updateStreamRegion(params: { + userId: UserID; + viewer: ThreadViewer; + streamKey: string; + region?: string; + }): Promise { const parsedKey = this.getParsedStreamKeyOrThrow(params.streamKey); const channelId = this.getChannelIdFromParsedKeyOrThrow(parsedKey); await this.assertStreamMutationAccess({ userId: params.userId, + viewer: params.viewer, channelId, parsedKey, }); @@ -164,7 +174,7 @@ export class StreamService { ); } - async getPreview(params: {userId: UserID; streamKey: string}): Promise<{ + async getPreview(params: {userId: UserID; viewer: ThreadViewer; streamKey: string}): Promise<{ buffer: Uint8Array; contentType: string; } | null> { @@ -172,6 +182,7 @@ export class StreamService { const channelId = this.getChannelIdFromParsedKeyOrThrow(parsedKey); await this.assertStreamChannelAccess({ userId: params.userId, + viewer: params.viewer, channelId, parsedKey, }); @@ -180,6 +191,7 @@ export class StreamService { async uploadPreview(params: { userId: UserID; + viewer: ThreadViewer; streamKey: string; channelId: ChannelID; thumbnail: string; @@ -188,6 +200,7 @@ export class StreamService { const parsedKey = this.getParsedStreamKeyOrThrow(params.streamKey); await this.assertStreamMutationAccess({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, parsedKey, }); @@ -206,6 +219,7 @@ export class StreamService { async createPreviewUploadUrl(params: { userId: UserID; + viewer: ThreadViewer; streamKey: string; channelId: ChannelID; contentType?: string; @@ -214,6 +228,7 @@ export class StreamService { const parsedKey = this.getParsedStreamKeyOrThrow(params.streamKey); await this.assertStreamMutationAccess({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, parsedKey, }); @@ -226,11 +241,12 @@ export class StreamService { }); } - async deletePreview(params: {userId: UserID; streamKey: string}): Promise { + async deletePreview(params: {userId: UserID; viewer: ThreadViewer; streamKey: string}): Promise { const parsedKey = this.getParsedStreamKeyOrThrow(params.streamKey); const channelId = this.getChannelIdFromParsedKeyOrThrow(parsedKey); await this.assertStreamMutationAccess({ userId: params.userId, + viewer: params.viewer, channelId, parsedKey, }); diff --git a/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts b/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts index 9d1ae8263..577cccc07 100644 --- a/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts +++ b/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts @@ -10,14 +10,38 @@ import { } from '@app/api/channel/services/ChannelFollowers'; import type {ChannelAuthService} from '@app/api/channel/services/channel_data/ChannelAuthService'; import type {ChannelUtilsService} from '@app/api/channel/services/channel_data/ChannelUtilsService'; +import {dispatchThreadEvents} from '@app/api/channel/services/thread/ThreadDispatch'; +import { + loadConvertibleParentThreads, + PARENT_CONVERSION_LOCK_TTL_SECONDS, + retypedThreadEvents, + retypeParentThreads, +} from '@app/api/channel/services/thread/ThreadParentConversion'; +import { + buildThreadParentPatch, + loadThreadParentConfig, + serializeThreadParentForAudit, + type ThreadParentSettingsInput, +} from '@app/api/channel/services/thread/ThreadParentSettings'; +import {enqueueDeleteChannelThreads} from '@app/api/channel/threads/ThreadJobs'; +import { + everEnabled, + guildActive, + isTainted, + THREAD_FEATURE_CHANNEL_TYPES, + type ThreadViewer, + viewerActive, +} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import {ChannelHelpers} from '@app/api/guild/services/channel/ChannelHelpers'; import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement'; +import {hasThreadPermissionBits, resolveProtectedBitActor} from '@app/api/guild/services/ThreadPermissionBits'; import {contentModerationService} from '@app/api/infrastructure/ContentModerationService'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService'; +import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore'; import type {IInviteRepository} from '@app/api/invite/IInviteRepository'; import {Logger} from '@app/api/Logger'; @@ -26,17 +50,17 @@ import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder' import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {Channel} from '@app/api/models/Channel'; import {ChannelPermissionOverwrite} from '@app/api/models/ChannelPermissionOverwrite'; +import type {ThreadState} from '@app/api/models/ThreadState'; import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {serializeChannelForAudit} from '@app/api/utils/AuditSerializationUtils'; -import {applyProtectedOverwriteBits} from '@app/api/utils/featureUtils'; +import {applyProtectedOverwriteBits, permissionWriteMask, protectedThreadBits} from '@app/api/utils/featureUtils'; import {overwriteGrantedBits} from '@app/api/utils/PermissionUtils'; import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService'; import type {VoiceRegionAvailability} from '@app/api/voice/VoiceModel'; import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import { - ALL_PERMISSIONS, ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES, ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, @@ -45,6 +69,8 @@ import { } from '@fluxer/constants/src/ChannelConstants'; import {ContentWarningLevel, clampVoiceChannelBitrate, GuildFeatures} from '@fluxer/constants/src/GuildConstants'; import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants'; +import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; +import {withImplicitThreadBits} from '@fluxer/constants/src/ThreadPermissionUtils'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {ChannelHasFollowedChannelsError} from '@fluxer/errors/src/domains/channel/ChannelHasFollowedChannelsError'; import {ChannelTypeConversionNotSupportedError} from '@fluxer/errors/src/domains/channel/ChannelTypeConversionNotSupportedError'; @@ -84,6 +110,13 @@ export interface ChannelUpdateData { nicks?: Record | null; } +function assertOverwriteTarget(channel: Channel, viewer: ThreadViewer | undefined): void { + if (!THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) return; + const active = viewer !== undefined && channel.guildId !== null && viewerActive(viewer, channel.guildId); + if (!active) throw new UnknownChannelError(); + if (channel.isThread()) throw new InvalidChannelTypeError(); +} + export class ChannelOperationsService { constructor( private channelRepository: IChannelRepositoryAggregate, @@ -101,20 +134,24 @@ export class ChannelOperationsService { private limitConfigService: LimitConfigService, private rateLimitService: IRateLimitService, private cacheService: ICacheService, + private snowflakeService: ISnowflakeService, ) {} async getChannel({ userId, + viewer, channelId, skipNsfwValidation, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; skipNsfwValidation?: boolean; }): Promise { const {channel} = await this.channelAuthService.getChannelAuthenticated({ userId, channelId, + viewer, skipNsfwValidation, }); return channel; @@ -138,24 +175,29 @@ export class ChannelOperationsService { async editChannel({ userId, + viewer, channelId, data, clientFeatures, requestCache, auditLogReason, typeConversion, + threadParent, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; data: ChannelUpdateData; clientFeatures: ReadonlySet; requestCache: RequestCache; auditLogReason: string | null; typeConversion?: ChannelTypeConversion | null; + threadParent?: ThreadParentSettingsInput | null; }): Promise { const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({ userId, channelId, + viewer, skipNsfwValidation: true, }); if (channel.type === ChannelTypes.GROUP_DM) { @@ -165,6 +207,18 @@ export class ChannelOperationsService { await checkPermission(Permissions.MANAGE_CHANNELS); const nextType = resolveNextChannelType(channel, typeConversion ?? null); const guildIdValue = createGuildID(BigInt(guild.id)); + const parentConfig = await loadThreadParentConfig(this.channelRepository.threads, channel); + const parentPatch = + threadParent && guildActive(guildIdValue) + ? await buildThreadParentPatch({ + channelType: channel.type, + guildId: guildIdValue, + input: threadParent, + current: parentConfig, + guildRepository: this.guildRepository, + generateId: () => this.snowflakeService.generate(), + }) + : null; contentModerationService.scanText(data.name ?? null, { userId, guildId: guildIdValue, @@ -180,7 +234,7 @@ export class ChannelOperationsService { surface: 'profile_field', }); let channelName = data.name ?? channel.name; - if (data.name !== undefined && isTextNamedChannelType(channel.type)) { + if (data.name !== undefined && (isTextNamedChannelType(channel.type) || channel.isThreadOnly())) { const hasFlexibleNamesEnabled = guild.features?.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) ?? false; if (!hasFlexibleNamesEnabled) { channelName = ChannelNameType.parse(data.name); @@ -219,25 +273,34 @@ export class ChannelOperationsService { const guildId = createGuildID(BigInt(guild.id)); await checkPermission(Permissions.MANAGE_ROLES); const isOwner = guild.owner_id === userId.toString(); - const channelPermissions = await this.gatewayService.getUserPermissions({ + const gatewayPermissions = await this.gatewayService.getUserPermissions({ guildId, userId, channelId: channel.id, }); + const actor = await resolveProtectedBitActor({ + guildId, + userId, + clientFeatures, + viewer, + isBot: async () => viewer.kind === 'user' && viewer.bot, + }); + const writeMask = permissionWriteMask(actor); + const channelPermissions = actor.threadBits ? withImplicitThreadBits(gatewayPermissions) : gatewayPermissions; permissionOverwrites = new Map(); for (const overwrite of data.permission_overwrites ?? []) { const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id); const existing = previousPermissionOverwrites?.get(targetId); const protectedBits = applyProtectedOverwriteBits( { - allow: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS, - deny: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS, + allow: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & writeMask, + deny: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & writeMask, }, { allow: existing?.allow ?? 0n, deny: existing?.deny ?? 0n, }, - clientFeatures, + actor, ); permissionOverwrites.set( targetId, @@ -248,6 +311,19 @@ export class ChannelOperationsService { }), ); } + const keptBits = protectedThreadBits(actor); + if (keptBits !== 0n) { + for (const [targetId, previous] of previousPermissionOverwrites ?? []) { + if (permissionOverwrites.has(targetId)) continue; + const allow = previous.allow & keptBits; + const deny = previous.deny & keptBits; + if (allow === 0n && deny === 0n) continue; + permissionOverwrites.set( + targetId, + new ChannelPermissionOverwrite({type: previous.type, allow_: allow, deny_: deny}), + ); + } + } if (!isOwner) { const targetIds = new Set([...(previousPermissionOverwrites?.keys() ?? []), ...permissionOverwrites.keys()]); for (const targetId of targetIds) { @@ -255,7 +331,7 @@ export class ChannelOperationsService { previousPermissionOverwrites?.get(targetId), permissionOverwrites.get(targetId), ); - if ((grantedBits & ~channelPermissions) !== 0n) { + if ((grantedBits & ~keptBits & ~channelPermissions) !== 0n) { throw new MissingPermissionsError(); } } @@ -292,7 +368,7 @@ export class ChannelOperationsService { ? data.voice_connection_limit : channel.voiceConnectionLimit, rate_limit_per_user: - data.rate_limit_per_user !== undefined && GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) + data.rate_limit_per_user !== undefined && acceptsRateLimit(channel.type) ? data.rate_limit_per_user : channel.rateLimitPerUser, nsfw: resolveNsfwOverrideWrite(channel, data), @@ -309,26 +385,54 @@ export class ChannelOperationsService { ]), ), }; + let retypedThreads: Array = []; + const toAnnouncement = + nextType === ChannelTypes.GUILD_ANNOUNCEMENT && channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT; const updatedChannel = - nextType === ChannelTypes.GUILD_ANNOUNCEMENT && channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT - ? await withChannelFollowLock(this.cacheService, channelId, async () => { - const webhooks = await this.webhookRepository.listByChannel(channelId); - if (webhooks.some((webhook) => webhook.type === WebhookTypes.CHANNEL_FOLLOWER)) { - throw new ChannelHasFollowedChannelsError(); - } - return await this.channelRepository.channelData.upsert(updatedChannelData); - }) + toAnnouncement || (nextType !== channel.type && everEnabled()) + ? await withChannelFollowLock( + this.cacheService, + channelId, + async () => { + if (toAnnouncement) { + const webhooks = await this.webhookRepository.listByChannel(channelId); + if (webhooks.some((webhook) => webhook.type === WebhookTypes.CHANNEL_FOLLOWER)) { + throw new ChannelHasFollowedChannelsError(); + } + } + const threads = await loadConvertibleParentThreads( + this.channelRepository, + guildIdValue, + channel, + nextType, + ); + if (threads.length === 0) return this.channelRepository.channelData.upsert(updatedChannelData); + const {parent, active} = await retypeParentThreads(this.channelRepository, threads, nextType, () => + this.channelRepository.channelData.upsert(updatedChannelData), + ); + retypedThreads = active; + return parent; + }, + everEnabled() ? PARENT_CONVERSION_LOCK_TTL_SECONDS : undefined, + ) : await this.channelRepository.channelData.upsert(updatedChannelData); if (channel.type === ChannelTypes.GUILD_ANNOUNCEMENT && nextType !== ChannelTypes.GUILD_ANNOUNCEMENT) { await enqueueChannelFollowerRemoval({sourceChannelId: channelId, reason: 'converted'}); } + if (parentPatch) await this.channelRepository.threads.patchParentConfig(guildIdValue, channel.id, parentPatch); + const nextParentConfig = parentPatch + ? await loadThreadParentConfig(this.channelRepository.threads, updatedChannel) + : parentConfig; if ( data.rate_limit_per_user !== undefined && - GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) && + acceptsRateLimit(channel.type) && data.rate_limit_per_user !== channel.rateLimitPerUser ) { try { await this.rateLimitService.clearLimitsByIdentifierPrefix(`slowmode:${channelId}:`); + if (everEnabled() && (await isTainted(guildIdValue))) { + await this.rateLimitService.clearLimitsByIdentifierPrefix(`slowmode-thread:${channelId}:`); + } } catch (error) { Logger.error( {error, channelId: channelId.toString()}, @@ -337,6 +441,11 @@ export class ChannelOperationsService { } } await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChannel, requestCache}); + await dispatchThreadEvents( + this.gatewayService, + guildIdValue, + await retypedThreadEvents(this.channelRepository, updatedChannel, retypedThreads), + ); if (channel.type === ChannelTypes.GUILD_CATEGORY && data.permission_overwrites !== undefined && guild) { await this.propagatePermissionsToSyncedChildren({ categoryChannel: updatedChannel, @@ -356,8 +465,14 @@ export class ChannelOperationsService { channelId, }); } - const beforeSnapshot = serializeChannelForAudit(channel); - const afterSnapshot = serializeChannelForAudit(updatedChannel); + const beforeSnapshot = { + ...serializeChannelForAudit(channel), + ...serializeThreadParentForAudit(channel.type, parentConfig), + }; + const afterSnapshot = { + ...serializeChannelForAudit(updatedChannel), + ...serializeThreadParentForAudit(updatedChannel.type, nextParentConfig), + }; const changes = this.guildAuditLogService.computeChanges(beforeSnapshot, afterSnapshot); if (changes.length > 0) { const builder = this.guildAuditLogService @@ -398,11 +513,13 @@ export class ChannelOperationsService { async deleteChannel({ userId, + viewer, channelId, requestCache, auditLogReason, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; requestCache: RequestCache; auditLogReason: string | null; @@ -410,6 +527,7 @@ export class ChannelOperationsService { const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({ userId, channelId, + viewer, skipNsfwValidation: true, }); if (this.channelAuthService.isPersonalNotesChannel({userId, channelId})) { @@ -419,13 +537,14 @@ export class ChannelOperationsService { await checkPermission(Permissions.MANAGE_CHANNELS); const guildId = createGuildID(BigInt(guild.id)); if (channel.type === ChannelTypes.GUILD_CATEGORY) { - const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId); + const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId, 'maintenance'); const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === channelId); for (const childChannel of childChannels) { const updatedChild = await this.channelRepository.channelData.upsert({ ...childChannel.toRow(), parent_id: null, }); + if (THREAD_ONLY_CHANNEL_TYPES.has(updatedChild.type) && !guildActive(guildId)) continue; await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache}); } } @@ -470,7 +589,11 @@ export class ChannelOperationsService { 'Failed to record guild audit log', ); } - await this.channelRepository.channelData.delete(channelId, guildId); + await this.channelRepository.channelData.delete(channelId, guildId, channel.type); + if (channel.isThreadParent() && everEnabled() && (await isTainted(guildId, {fresh: true}))) { + await enqueueDeleteChannelThreads(guildId, channelId); + await this.channelRepository.threads.deleteParentConfig(guildId, channelId); + } const guildModel = await this.guildRepository.findUnique(guildId); if (guildModel) { const guildRow = guildModel.toRow(); @@ -495,9 +618,11 @@ export class ChannelOperationsService { async getAvailableRtcRegions({ userId, + viewer, channelId, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; }): Promise> { if (this.voiceAvailabilityService === null) { @@ -506,6 +631,7 @@ export class ChannelOperationsService { const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({ userId, channelId, + viewer, skipNsfwValidation: true, }); if (channel.type !== ChannelTypes.GUILD_VOICE) { @@ -535,7 +661,7 @@ export class ChannelOperationsService { guildId: GuildID; requestCache: RequestCache; }): Promise { - const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId); + const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId, 'maintenance'); const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === categoryChannel.id); const syncedChannels: Array = []; for (const child of childChannels) { @@ -555,6 +681,7 @@ export class ChannelOperationsService { ]), ), }); + if (THREAD_ONLY_CHANNEL_TYPES.has(updatedChild.type) && !guildActive(guildId)) return; await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache}); }), ); @@ -617,7 +744,7 @@ export class ChannelOperationsService { if (params.channel.type === ChannelTypes.GUILD_CATEGORY) { throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.CATEGORIES_CANNOT_HAVE_PARENTS); } - const guildChannels = await this.channelRepository.channelData.listGuildChannels(params.guildId); + const guildChannels = await this.channelRepository.channelData.listGuildChannels(params.guildId, 'enrolled'); const parentChannel = guildChannels.find((channel) => channel.id === params.parentId); if (!parentChannel) { throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.INVALID_PARENT_CHANNEL); @@ -661,11 +788,13 @@ export class ChannelOperationsService { deny_: bigint; }; clientFeatures: ReadonlySet; + viewer?: ThreadViewer; requestCache: RequestCache; auditLogReason: string | null; }): Promise { const channel = await this.channelRepository.channelData.findUnique(params.channelId); if (!channel?.guildId) throw new UnknownChannelError(); + assertOverwriteTarget(channel, params.viewer); await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id}); const userPermissions = await this.gatewayService.getUserPermissions({ guildId: channel.guildId, @@ -674,22 +803,31 @@ export class ChannelOperationsService { }); const targetId = params.overwrite.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId); const existing = channel.permissionOverwrites?.get(targetId); + const actor = await resolveProtectedBitActor({ + guildId: channel.guildId, + userId: params.userId, + clientFeatures: params.clientFeatures, + viewer: params.viewer, + isBot: async () => (await this.userRepository.findUnique(params.userId))?.isBot ?? false, + }); + const writeMask = permissionWriteMask(actor); const protectedBits = applyProtectedOverwriteBits( { - allow: params.overwrite.allow_ & ALL_PERMISSIONS, - deny: params.overwrite.deny_ & ALL_PERMISSIONS, + allow: params.overwrite.allow_ & writeMask, + deny: params.overwrite.deny_ & writeMask, }, { allow: existing?.allow ?? 0n, deny: existing?.deny ?? 0n, }, - params.clientFeatures, + actor, ); const sanitizedAllow = protectedBits.allow; const sanitizedDeny = protectedBits.deny; const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n; const grantedBits = overwriteGrantedBits(existing, {allow: sanitizedAllow, deny: sanitizedDeny}); - if (!hasAdministrator && (grantedBits & ~userPermissions) !== 0n) throw new MissingPermissionsError(); + const effectivePermissions = actor.threadBits ? withImplicitThreadBits(userPermissions) : userPermissions; + if (!hasAdministrator && (grantedBits & ~effectivePermissions) !== 0n) throw new MissingPermissionsError(); const previousPermissionOverwrites = channel.permissionOverwrites; const nextOverwrite = new ChannelPermissionOverwrite({ type: params.overwrite.type, @@ -698,12 +836,15 @@ export class ChannelOperationsService { }); const overwrites = new Map(channel.permissionOverwrites ?? []); overwrites.set(targetId, nextOverwrite); - const updated = await this.channelRepository.channelData.upsert({ - ...channel.toRow(), - permission_overwrites: new Map( - Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]), - ), - }); + const updated = await this.channelRepository.channelData.upsert( + { + ...channel.toRow(), + permission_overwrites: new Map( + Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]), + ), + }, + channel.toRow(), + ); await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache}); if (channel.type === ChannelTypes.GUILD_CATEGORY) { await this.propagatePermissionsToSyncedChildren({ @@ -727,17 +868,32 @@ export class ChannelOperationsService { userId: UserID; channelId: ChannelID; overwriteId: bigint; + clientFeatures?: ReadonlySet; + viewer?: ThreadViewer; requestCache: RequestCache; auditLogReason: string | null; }): Promise { const channel = await this.channelRepository.channelData.findUnique(params.channelId); if (!channel?.guildId) throw new UnknownChannelError(); + assertOverwriteTarget(channel, params.viewer); await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id}); const previousPermissionOverwrites = channel.permissionOverwrites; const overwrites = new Map(channel.permissionOverwrites ?? []); const removedRole = overwrites.get(createRoleID(params.overwriteId)); const removedUser = overwrites.get(createUserID(params.overwriteId)); const removed = removedRole ?? removedUser; + const kept = + removed && (hasThreadPermissionBits(removed.allow) || hasThreadPermissionBits(removed.deny)) + ? protectedThreadBits( + await resolveProtectedBitActor({ + guildId: channel.guildId, + userId: params.userId, + clientFeatures: params.clientFeatures ?? new Set(), + viewer: params.viewer, + isBot: async () => (await this.userRepository.findUnique(params.userId))?.isBot ?? false, + }), + ) + : 0n; if (removed) { const userPermissions = await this.gatewayService.getUserPermissions({ guildId: channel.guildId, @@ -745,16 +901,30 @@ export class ChannelOperationsService { channelId: channel.id, }); const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n; - if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError(); + if (!hasAdministrator && (removed.deny & ~kept & ~userPermissions) !== 0n) throw new MissingPermissionsError(); } overwrites.delete(createRoleID(params.overwriteId)); overwrites.delete(createUserID(params.overwriteId)); - const updated = await this.channelRepository.channelData.upsert({ - ...channel.toRow(), - permission_overwrites: new Map( - Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]), - ), - }); + if (removed && kept !== 0n) { + const removedTargetId = removed.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId); + overwrites.set( + removedTargetId, + new ChannelPermissionOverwrite({ + type: removed.type, + allow_: removed.allow & kept, + deny_: removed.deny & kept, + }), + ); + } + const updated = await this.channelRepository.channelData.upsert( + { + ...channel.toRow(), + permission_overwrites: new Map( + Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]), + ), + }, + channel.toRow(), + ); await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache}); if (channel.type === ChannelTypes.GUILD_CATEGORY) { await this.propagatePermissionsToSyncedChildren({ @@ -807,10 +977,15 @@ function isWritableGuildChannel(type: number): boolean { type === ChannelTypes.GUILD_ANNOUNCEMENT || type === ChannelTypes.GUILD_VOICE || type === ChannelTypes.GUILD_LINK || - type === ChannelTypes.GUILD_CATEGORY + type === ChannelTypes.GUILD_CATEGORY || + THREAD_ONLY_CHANNEL_TYPES.has(type) ); } +function acceptsRateLimit(type: number): boolean { + return GUILD_TEXT_BASED_CHANNEL_TYPES.has(type) || THREAD_ONLY_CHANNEL_TYPES.has(type); +} + function resolveNsfwOverrideWrite(channel: Channel, data: ChannelUpdateData): boolean | null { if (!isWritableGuildChannel(channel.type)) { return channel.nsfwOverride; diff --git a/fluxer_api/src/api/channel/services/channel_data/ChannelUtilsService.ts b/fluxer_api/src/api/channel/services/channel_data/ChannelUtilsService.ts index 665b7adc3..1640c1322 100644 --- a/fluxer_api/src/api/channel/services/channel_data/ChannelUtilsService.ts +++ b/fluxer_api/src/api/channel/services/channel_data/ChannelUtilsService.ts @@ -6,6 +6,7 @@ import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IC import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch'; import {dispatchMessageCreateBroadcast} from '@app/api/channel/services/message/MessageGatewayDispatch'; import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers'; +import {withThreadParentFields} from '@app/api/channel/services/thread/ThreadParentSettings'; import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {IStorageService} from '@app/api/infrastructure/IStorageService'; @@ -48,12 +49,16 @@ export class ChannelUtilsService { async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise { if (channel.guildId) { - const channelResponse = await mapChannelToResponse({ + const channelResponse = await withThreadParentFields( + this.channelRepository.threads, channel, - currentUserId: null, - userCacheService: this.userCacheService, - requestCache, - }); + await mapChannelToResponse({ + channel, + currentUserId: null, + userCacheService: this.userCacheService, + requestCache, + }), + ); await dispatchChannelEvent({ gatewayService: this.gatewayService, channel, diff --git a/fluxer_api/src/api/channel/services/interaction/MessageInteractionBase.ts b/fluxer_api/src/api/channel/services/interaction/MessageInteractionBase.ts index d9c92e455..f69dd5f63 100644 --- a/fluxer_api/src/api/channel/services/interaction/MessageInteractionBase.ts +++ b/fluxer_api/src/api/channel/services/interaction/MessageInteractionBase.ts @@ -3,6 +3,7 @@ import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {Channel} from '@app/api/models/Channel'; import {TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {CannotSendMessageToNonTextChannelError} from '@fluxer/errors/src/domains/channel/CannotSendMessageToNonTextChannelError'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; @@ -21,7 +22,7 @@ export abstract class MessageInteractionBase { } protected ensureTextChannel(channel: Channel): void { - if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) { + if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type) && !THREAD_CHANNEL_TYPES.has(channel.type)) { throw new CannotSendMessageToNonTextChannelError(); } } diff --git a/fluxer_api/src/api/channel/services/interaction/MessagePinService.ts b/fluxer_api/src/api/channel/services/interaction/MessagePinService.ts index 3025dcda9..296a30940 100644 --- a/fluxer_api/src/api/channel/services/interaction/MessagePinService.ts +++ b/fluxer_api/src/api/channel/services/interaction/MessagePinService.ts @@ -12,6 +12,7 @@ import { } from '@app/api/channel/services/message/MessageGatewayDispatch'; import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService'; import {createMessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService'; +import {assertThreadInteractionAllowed} from '@app/api/channel/services/thread/ThreadInteractionGuards'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; @@ -21,7 +22,9 @@ import type {Message} from '@app/api/models/Message'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; +import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {CannotEditSystemMessageError} from '@fluxer/errors/src/domains/channel/CannotEditSystemMessageError'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError'; import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; import type {ChannelPinResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; @@ -75,6 +78,7 @@ export class MessagePinService extends MessageInteractionBase { has_more: boolean; }> { const {channel} = authChannel; + if (THREAD_ONLY_CHANNEL_TYPES.has(channel.type)) throw new InvalidChannelTypeError(); this.ensureTextChannel(channel); const hasReadHistory = !authChannel.guild || (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY)); if (!hasReadHistory) { @@ -158,6 +162,7 @@ export class MessagePinService extends MessageInteractionBase { } } this.ensureTextChannel(channel); + assertThreadInteractionAllowed(authChannel, 'pin'); await this.assertMessageHistoryAccess({authChannel, messageId}); const message = await this.channelRepository.messages.getMessage(channel.id, messageId); if (!message) throw new UnknownMessageError(); @@ -168,7 +173,7 @@ export class MessagePinService extends MessageInteractionBase { const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow()); await this.channelRepository.messageInteractions.addChannelPin(channel.id, messageId, now); const updatedChannelData = {...channel.toRow(), last_pin_timestamp: now}; - const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData); + const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData, channel.toRow()); await this.dispatchChannelPinsUpdate(updatedChannel); await this.sendPinSystemMessage({channel, message, userId}); await dispatchMessageUpdateBroadcast({ @@ -184,6 +189,7 @@ export class MessagePinService extends MessageInteractionBase { channel_id: channel.id.toString(), message_id: messageId.toString(), }) + .withThreadScope(channel.isThread()) .withReason(auditLogReason ?? null) .commit(); } @@ -209,6 +215,7 @@ export class MessagePinService extends MessageInteractionBase { } } this.ensureTextChannel(channel); + assertThreadInteractionAllowed(authChannel, 'pin'); await this.assertMessageHistoryAccess({authChannel, messageId}); const message = await this.channelRepository.messages.getMessage(channel.id, messageId); if (!message) throw new UnknownMessageError(); @@ -231,6 +238,7 @@ export class MessagePinService extends MessageInteractionBase { channel_id: channel.id.toString(), message_id: messageId.toString(), }) + .withThreadScope(channel.isThread()) .withReason(auditLogReason ?? null) .commit(); } diff --git a/fluxer_api/src/api/channel/services/interaction/MessageReactionService.ts b/fluxer_api/src/api/channel/services/interaction/MessageReactionService.ts index 06ecef691..e2ec71514 100644 --- a/fluxer_api/src/api/channel/services/interaction/MessageReactionService.ts +++ b/fluxer_api/src/api/channel/services/interaction/MessageReactionService.ts @@ -6,6 +6,7 @@ import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IC import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch'; import {MessageInteractionBase, type ParsedEmoji} from '@app/api/channel/services/interaction/MessageInteractionBase'; +import {assertThreadInteractionAllowed} from '@app/api/channel/services/thread/ThreadInteractionGuards'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; @@ -162,6 +163,7 @@ export class MessageReactionService extends MessageInteractionBase { const channel = authChannel.channel; const {guild, hasPermission, checkPermission} = authChannel; this.ensureTextChannel(channel); + assertThreadInteractionAllowed(authChannel, 'react'); assertGuildMemberCanCommunicate(authChannel.member); await this.assertMessageHistoryAccess({authChannel, messageId}); if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) { @@ -266,6 +268,7 @@ export class MessageReactionService extends MessageInteractionBase { const channel = authChannel.channel; const {guild, hasPermission} = authChannel; this.ensureTextChannel(channel); + assertThreadInteractionAllowed(authChannel, 'react', {ignoreTimeout: true}); await this.assertMessageHistoryAccess({authChannel, messageId}); if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) { throw new FeatureTemporarilyDisabledError(); @@ -306,6 +309,7 @@ export class MessageReactionService extends MessageInteractionBase { const channel = authChannel.channel; const {guild, hasPermission} = authChannel; this.ensureTextChannel(channel); + assertThreadInteractionAllowed(authChannel, 'react', {ignoreTimeout: true}); await this.assertMessageHistoryAccess({authChannel, messageId}); if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) { throw new FeatureTemporarilyDisabledError(); @@ -338,6 +342,7 @@ export class MessageReactionService extends MessageInteractionBase { const channel = authChannel.channel; const {guild, hasPermission} = authChannel; this.ensureTextChannel(channel); + assertThreadInteractionAllowed(authChannel, 'react', {ignoreTimeout: true}); await this.assertMessageHistoryAccess({authChannel, messageId}); if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) { throw new FeatureTemporarilyDisabledError(); diff --git a/fluxer_api/src/api/channel/services/message/AttachmentProcessingService.ts b/fluxer_api/src/api/channel/services/message/AttachmentProcessingService.ts index 802564909..c8b235b48 100644 --- a/fluxer_api/src/api/channel/services/message/AttachmentProcessingService.ts +++ b/fluxer_api/src/api/channel/services/message/AttachmentProcessingService.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import fs from 'node:fs'; -import {createAttachmentID, type UserID} from '@app/api/BrandedTypes'; +import {type ChannelID, createAttachmentID, type UserID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; import type {AttachmentToProcess} from '@app/api/channel/AttachmentDTOs'; import type {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository'; @@ -45,6 +45,7 @@ interface ProcessAttachmentParams { attachment: AttachmentToProcess; index: number; uploadUserId: UserID; + uploadChannelId?: ChannelID; channel?: Channel; guild?: GuildResponse | null; member?: GuildMemberResponse | null; @@ -81,6 +82,7 @@ export class AttachmentProcessingService { message: Message; attachments: Array; uploadUserId: UserID; + uploadChannelId?: ChannelID; channel?: Channel; guild?: GuildResponse | null; member?: GuildMemberResponse | null; @@ -104,6 +106,7 @@ export class AttachmentProcessingService { attachment, index, uploadUserId: params.uploadUserId, + uploadChannelId: params.uploadChannelId, channel: params.channel, guild: params.guild, member: params.member, @@ -181,7 +184,7 @@ export class AttachmentProcessingService { const pendingUpload = await this.attachmentUploadTraceRepository.getPendingUpload({ uploadKey: attachment.upload_filename, userId: params.uploadUserId, - channelId: message.channelId, + channelId: params.uploadChannelId ?? message.channelId, }); if (!pendingUpload) { throw InputValidationError.fromCode( diff --git a/fluxer_api/src/api/channel/services/message/CrosspostDeliveryService.ts b/fluxer_api/src/api/channel/services/message/CrosspostDeliveryService.ts index 704d454bb..1a9608369 100644 --- a/fluxer_api/src/api/channel/services/message/CrosspostDeliveryService.ts +++ b/fluxer_api/src/api/channel/services/message/CrosspostDeliveryService.ts @@ -66,6 +66,7 @@ import { WebhookTypes, } from '@fluxer/constants/src/ChannelConstants'; import {GuildFeatures, GuildOperations} from '@fluxer/constants/src/GuildConstants'; +import {THREAD_MESSAGE_FLAG_MASK} from '@fluxer/constants/src/ThreadConstants'; import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; @@ -644,7 +645,10 @@ export class CrosspostDeliveryService { attachments: null, embeds: null, sticker_items: null, - flags: MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED, + flags: + MessageFlags.IS_CROSSPOST | + MessageFlags.SOURCE_MESSAGE_DELETED | + (fresh.flags & THREAD_MESSAGE_FLAG_MASK), edited_timestamp: new Date(), }, fresh.toRow(), diff --git a/fluxer_api/src/api/channel/services/message/MessageContentService.ts b/fluxer_api/src/api/channel/services/message/MessageContentService.ts index 5562adba5..c718fb8f0 100644 --- a/fluxer_api/src/api/channel/services/message/MessageContentService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageContentService.ts @@ -8,6 +8,7 @@ import type {IUserRepository} from '@app/api/user/IUserRepository'; import * as EmojiUtils from '@app/api/utils/EmojiUtils'; import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants'; import {GuildExplicitContentFilterTypes, GuildFeatures, GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants'; +import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {SensitiveMediaFilterLevel} from '@fluxer/constants/src/UserConstants'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; @@ -50,7 +51,11 @@ export class MessageContentService { if (isBot) { return true; } - if (channel && GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) && channel.isNsfw) { + if ( + channel && + (GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) || THREAD_ONLY_CHANNEL_TYPES.has(channel.type)) && + channel.isNsfw + ) { return true; } if (channel?.type === ChannelTypes.DM_PERSONAL_NOTES) { diff --git a/fluxer_api/src/api/channel/services/message/MessageCrosspostService.ts b/fluxer_api/src/api/channel/services/message/MessageCrosspostService.ts index d5708ebbc..45db87856 100644 --- a/fluxer_api/src/api/channel/services/message/MessageCrosspostService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageCrosspostService.ts @@ -15,6 +15,7 @@ import type {MessageDispatchService} from '@app/api/channel/services/message/Mes import {isCrosspostCopy, isOperationDisabled} from '@app/api/channel/services/message/MessageHelpers'; import {assertMessageWithinHistoryCutoff} from '@app/api/channel/services/message/MessageHistoryCutoff'; import type {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import {contentModerationService} from '@app/api/infrastructure/ContentModerationService'; import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; @@ -58,16 +59,18 @@ export class MessageCrosspostService { async crosspostMessage({ userId, + viewer, channelId, messageId, requestCache, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; requestCache: RequestCache; }): Promise { - const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({userId, channelId, viewer}); const {channel, guild, member, hasPermission, checkPermission} = authChannel; if (channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT) { throw new AnnouncementChannelRequiredError(); diff --git a/fluxer_api/src/api/channel/services/message/MessageDeleteService.ts b/fluxer_api/src/api/channel/services/message/MessageDeleteService.ts index f985a4add..72ca1c55c 100644 --- a/fluxer_api/src/api/channel/services/message/MessageDeleteService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageDeleteService.ts @@ -6,9 +6,14 @@ import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IC import type {CrosspostPropagation} from '@app/api/channel/services/message/CrosspostPropagation'; import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService'; import type {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService'; -import {isOperationDisabled, purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers'; +import { + decrementThreadMessageCount, + isOperationDisabled, + purgeMessageAttachments, +} from '@app/api/channel/services/message/MessageHelpers'; import type {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService'; import type {MessageValidationService} from '@app/api/channel/services/message/MessageValidationService'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; @@ -20,6 +25,7 @@ import type {Webhook} from '@app/api/models/Webhook'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; +import {TEXT_THREAD_PARENT_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError'; @@ -52,27 +58,39 @@ export class MessageDeleteService { async deleteMessage({ userId, + viewer, channelId, messageId, skipGuildAuditLog, auditLogReason, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; requestCache: RequestCache; skipGuildAuditLog?: boolean; auditLogReason?: string | null; }): Promise { - const {channel, guild, hasPermission} = await this.deps.channelAuthService.getChannelAuthenticated({ + const {channel, guild, hasPermission, thread} = await this.deps.channelAuthService.getChannelAuthenticated({ userId, channelId, + viewer, }); if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) { throw new FeatureTemporarilyDisabledError(); } const message = await this.deps.channelRepository.messages.getMessage(channelId, messageId); - if (!message) throw new UnknownMessageError(); + if (!message) { + if ( + thread?.state.hasStarter && + TEXT_THREAD_PARENT_CHANNEL_TYPES.has(thread.parent.type) && + messageId.toString() === channel.id.toString() + ) { + throw new MissingPermissionsError(); + } + throw new UnknownMessageError(); + } const canDelete = await this.deps.validationService.canDeleteMessage({message, userId, guild, hasPermission}); if (!canDelete) throw new MissingPermissionsError(); if (message.pinnedTimestamp) { @@ -85,6 +103,7 @@ export class MessageDeleteService { message.authorId || createUserID(0n), message.pinnedTimestamp || undefined, ); + await this.decrementThreadMessageCount(channel, [messageId]); await this.deps.dispatchService.dispatchMessageDelete({channel, messageId, message}); await this.deps.crosspostPropagation.enqueueCrosspostSourceRemoval({ messages: [message], @@ -106,6 +125,7 @@ export class MessageDeleteService { .createBuilder(channel.guildId, userId) .withAction(AuditLogActionType.MESSAGE_DELETE, message.id.toString()) .withMetadata({channel_id: channel.id.toString()}) + .withThreadScope(channel.isThread()) .withReason(auditLogReason ?? null) .commit(); } @@ -114,14 +134,16 @@ export class MessageDeleteService { async deleteWebhookMessage({ webhook, + thread, messageId, }: { webhook: Webhook; + thread?: Channel | null; messageId: MessageID; requestCache: RequestCache; }): Promise { - const channelId = webhook.channelId!; - const channel = await this.deps.channelRepository.channelData.findUnique(channelId); + const channelId = thread?.id ?? webhook.channelId!; + const channel = thread ?? (await this.deps.channelRepository.channelData.findUnique(channelId)); if (!channel?.guildId) { throw new CannotExecuteOnDmError(); } @@ -140,6 +162,7 @@ export class MessageDeleteService { message.authorId || createUserID(0n), message.pinnedTimestamp || undefined, ); + await this.decrementThreadMessageCount(channel, [messageId]); await this.deps.dispatchService.dispatchMessageDelete({channel, messageId, message}); await this.deps.crosspostPropagation.enqueueCrosspostSourceRemoval({ messages: [message], @@ -159,13 +182,19 @@ export class MessageDeleteService { await this.deps.searchService.deleteMessageIndex(messageId); } + private async decrementThreadMessageCount(channel: Channel, messageIds: Array): Promise { + await decrementThreadMessageCount(this.deps.channelRepository, channel, messageIds); + } + async bulkDeleteMessages({ userId, + viewer, channelId, messageIds, auditLogReason, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageIds: Array; auditLogReason?: string | null; @@ -179,6 +208,7 @@ export class MessageDeleteService { const {channel, guild, checkPermission} = await this.deps.channelAuthService.getChannelAuthenticated({ userId, channelId, + viewer, }); if (!guild) throw new CannotExecuteOnDmError(); await checkPermission(Permissions.MANAGE_MESSAGES); @@ -193,7 +223,12 @@ export class MessageDeleteService { ), ); await this.deps.channelRepository.messages.bulkDeleteMessages(channelId, messageIds); - await this.deps.dispatchService.dispatchMessageDeleteBulk({channel, messageIds}); + const existingIds = existingMessages.map((message) => message.id); + await this.decrementThreadMessageCount(channel, existingIds); + await this.deps.dispatchService.dispatchMessageDeleteBulk({ + channel, + messageIds: channel.isThread() ? existingIds : messageIds, + }); await this.deps.crosspostPropagation.enqueueCrosspostSourceRemoval({ messages: existingMessages, mode: 'source_deleted', @@ -207,16 +242,25 @@ export class MessageDeleteService { channel_id: channel.id.toString(), count: existingMessages.length.toString(), }) + .withThreadScope(channel.isThread()) .withReason(auditLogReason ?? null) .commit(); } await this.deps.searchService.deleteMessagesIndex(messageIds); } - async purgePersonalNotesMessages({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<{ + async purgePersonalNotesMessages({ + userId, + channelId, + viewer, + }: { + userId: UserID; + viewer: ThreadViewer; + channelId: ChannelID; + }): Promise<{ deletedCount: number; }> { - const {channel} = await this.deps.channelAuthService.getChannelAuthenticated({userId, channelId}); + const {channel} = await this.deps.channelAuthService.getChannelAuthenticated({userId, channelId, viewer}); if ( channel.type !== ChannelTypes.DM_PERSONAL_NOTES || !this.deps.channelAuthService.isPersonalNotesChannel({userId, channelId}) @@ -252,8 +296,16 @@ export class MessageDeleteService { guildId: GuildID; seconds: number; }): Promise { - const channels = await this.deps.channelRepository.channelData.listGuildChannels(guildId); const cutoffTimestamp = Date.now() - seconds * ms('1 second'); + const guildChannels = await this.deps.channelRepository.channelData.listGuildChannels(guildId, 'complete'); + const threadIds = await this.deps.channelRepository.threads.listGuildThreadIds(guildId, { + parents: guildChannels, + activeSince: new Date(cutoffTimestamp), + }); + const channels = + threadIds.length > 0 + ? [...guildChannels, ...(await this.deps.channelRepository.channelData.listChannels(threadIds))] + : guildChannels; const cutoffSnowflake = createMessageID(createSnowflakeFromTimestamp(cutoffTimestamp)); await Promise.all( channels.map(async (channel: Channel) => { @@ -276,6 +328,7 @@ export class MessageDeleteService { ), ); await this.deps.channelRepository.messages.bulkDeleteMessages(channel.id, messageIds); + await this.decrementThreadMessageCount(channel, messageIds); await this.deps.dispatchService.dispatchMessageDeleteBulk({channel, messageIds}); await this.deps.crosspostPropagation.enqueueCrosspostSourceRemoval({ messages: userMessages, diff --git a/fluxer_api/src/api/channel/services/message/MessageEditService.ts b/fluxer_api/src/api/channel/services/message/MessageEditService.ts index dd4f78160..d55ecc7dc 100644 --- a/fluxer_api/src/api/channel/services/message/MessageEditService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageEditService.ts @@ -16,6 +16,8 @@ import type {MessageProcessingService} from '@app/api/channel/services/message/M import type {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService'; import type {MessageValidationService} from '@app/api/channel/services/message/MessageValidationService'; import type {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock'; +import {assertThreadInteractionAllowed} from '@app/api/channel/services/thread/ThreadInteractionGuards'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {Message} from '@app/api/models/Message'; @@ -57,12 +59,14 @@ export class MessageEditService { async editMessage({ userId, + viewer, channelId, messageId, data, requestCache, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; data: MessageUpdateRequest; @@ -71,6 +75,7 @@ export class MessageEditService { const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({ userId, channelId, + viewer, }); const {channel, guild, hasPermission, member} = authChannel; const hasNewAttachments = @@ -99,6 +104,7 @@ export class MessageEditService { if (message.authorId === userId) { assertGuildMemberCanCommunicate(member); } + assertThreadInteractionAllowed(authChannel, 'edit', {ignoreTimeout: message.authorId !== userId}); if (data.message_snapshots !== undefined) { throw new MissingPermissionsError(); } diff --git a/fluxer_api/src/api/channel/services/message/MessageHelpers.ts b/fluxer_api/src/api/channel/services/message/MessageHelpers.ts index 0c0042a8d..02e685f6f 100644 --- a/fluxer_api/src/api/channel/services/message/MessageHelpers.ts +++ b/fluxer_api/src/api/channel/services/message/MessageHelpers.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {AttachmentID, ChannelID, UserID} from '@app/api/BrandedTypes'; +import type {AttachmentID, ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; import {createAttachmentID, userIdToChannelId} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; import {forEachEmbedMedia} from '@app/api/channel/services/message/CrosspostEmbedObjects'; import type { MessageSnapshot as CassandraMessageSnapshot, @@ -18,6 +19,7 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils'; import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder'; import {Attachment} from '@app/api/models/Attachment'; +import type {Channel} from '@app/api/models/Channel'; import type {Message} from '@app/api/models/Message'; import {MessageSnapshot as MessageSnapshotModel} from '@app/api/models/MessageSnapshot'; import type {User} from '@app/api/models/User'; @@ -469,3 +471,19 @@ export function isMessageEmpty(message: Message, excludingAttachments = false): export function collectMessageAttachments(message: Message): Array { return [...message.attachments, ...message.messageSnapshots.flatMap((snapshot) => snapshot.attachments)]; } + +export function countedThreadMessages(channel: Channel, messageIds: Array): number { + if (!channel.isThread()) return 0; + const starterId = channel.id.toString(); + return messageIds.filter((messageId) => messageId.toString() !== starterId).length; +} + +export async function decrementThreadMessageCount( + channelRepository: IChannelRepositoryAggregate, + channel: Channel | null, + messageIds: Array, +): Promise { + if (!channel) return; + const count = countedThreadMessages(channel, messageIds); + if (count > 0) await channelRepository.threads.adjustMessageCount(channel.id, -count); +} diff --git a/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts b/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts index 5f2883c3d..98d646093 100644 --- a/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts +++ b/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts @@ -19,6 +19,7 @@ import { keepOwnedEmbedAttachments, } from '@app/api/channel/services/message/MessageHelpers'; import {MessageStickerService} from '@app/api/channel/services/message/MessageStickerService'; +import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope'; import {getContentMessage} from '@app/api/content_i18n/ContentI18n'; import type { MessageAttachment, @@ -93,6 +94,7 @@ interface CreateMessageParams { embeds?: Array; attachments?: Array; attachmentUploadUserId?: UserID; + uploadChannelId?: ChannelID; processedAttachments?: Array; stickerIds?: Array; messageReference?: MessageReference; @@ -116,6 +118,7 @@ interface CreateMessageParams { processedEmbeds?: Array; processedStickerItems?: Array; skipDeferredEmbeds?: boolean; + threadInsert?: boolean; } export class MessagePersistenceService { @@ -151,6 +154,10 @@ export class MessagePersistenceService { this.attachmentDecayService = new AttachmentDecayService(); } + private nsfwScopeChannel(channel: Channel): Promise { + return resolveNsfwScopeChannel(channel, (channelId) => this.channelRepository.channelData.findUnique(channelId)); + } + getEmbedAttachmentResolver(): MessageEmbedAttachmentResolver { return this.embedAttachmentResolver; } @@ -169,7 +176,7 @@ export class MessagePersistenceService { const isBot = params.user?.isBot ?? false; const isBugHunterBot = isBot && ((params.user?.flags ?? 0n) & UserFlags.BUG_HUNTER) !== 0n; const isNSFWAllowed = this.contentService.isNSFWContentAllowed({ - channel: params.channel, + channel: params.channel ? await this.nsfwScopeChannel(params.channel) : undefined, guild: params.guild, member: params.member, isBot, @@ -248,7 +255,11 @@ export class MessagePersistenceService { has_reaction: false, version: 1, }; - const message = await this.channelRepository.messages.upsertMessage(messageRowData, null); + const message = await this.channelRepository.messages.upsertMessage( + messageRowData, + null, + params.threadInsert ? {isInsert: true} : undefined, + ); const enqueueDeferredEmbeds = await this.runPostPersistenceOperations({ message, params, @@ -297,6 +308,7 @@ export class MessagePersistenceService { } as Message, attachments: params.attachments, uploadUserId, + uploadChannelId: params.uploadChannelId, channel: params.channel, guild: params.guild, member: params.member, @@ -367,6 +379,7 @@ export class MessagePersistenceService { mentionCount: 0, implicit: {unreadThrough: params.user ? (params.channel?.lastMessageId ?? null) : null}, emitGateway: false, + ...(params.threadInsert ? {capable: true, channel: params.channel ?? null} : {}), }), ); } @@ -395,7 +408,7 @@ export class MessagePersistenceService { throw InputValidationError.fromCode('message', ValidationErrorCodes.MESSAGES_WITH_SNAPSHOTS_CANNOT_BE_EDITED); } const isNSFWAllowed = this.contentService.isNSFWContentAllowed({ - channel, + channel: await this.nsfwScopeChannel(channel), guild, member, isBot: params.isBot, @@ -715,6 +728,7 @@ export class MessagePersistenceService { guildId: params.guildId ?? null, allowEmbeds: false, messageReference: params.messageReference, + threadInsert: true, mentionData: { flags: 0, mentionUserIds: params.mentionUserIds ?? [], diff --git a/fluxer_api/src/api/channel/services/message/MessageRequestParser.ts b/fluxer_api/src/api/channel/services/message/MessageRequestParser.ts index 9b3036db6..ba7322993 100644 --- a/fluxer_api/src/api/channel/services/message/MessageRequestParser.ts +++ b/fluxer_api/src/api/channel/services/message/MessageRequestParser.ts @@ -10,6 +10,7 @@ import { import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import type {MessageRequest, MessageUpdateRequest} from '@app/api/channel/MessageTypes'; import {normalizeMessageRequestPayload} from '@app/api/channel/services/message/MessageRequestCompatibility'; +import {SYSTEM_THREAD_VIEWER, viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildService} from '@app/api/guild/services/GuildService'; import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils'; @@ -159,6 +160,7 @@ export async function parseMultipartMessageData( .get('channelService') .attachments.uploadFormDataAttachments({ userId: user.id, + viewer: options?.actor === 'webhook' ? SYSTEM_THREAD_VIEWER : viewerFromCtx(ctx), channelId, clientIp, files: filesWithIndices, diff --git a/fluxer_api/src/api/channel/services/message/MessageRequestService.test.ts b/fluxer_api/src/api/channel/services/message/MessageRequestService.test.ts new file mode 100644 index 000000000..f36d4876c --- /dev/null +++ b/fluxer_api/src/api/channel/services/message/MessageRequestService.test.ts @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createChannelID, createGuildID, createMessageID, createUserID} from '@app/api/BrandedTypes'; +import type {ChannelService} from '@app/api/channel/services/ChannelService'; +import type {CrosspostSourceService} from '@app/api/channel/services/message/CrosspostSourceService'; +import {MessageRequestService} from '@app/api/channel/services/message/MessageRequestService'; +import type {MessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService'; +import {setCassandraQueryExecutorForTesting, upsertOne} from '@app/api/database/CassandraQueryExecution'; +import type {CassandraParams, PreparedQuery} from '@app/api/database/CassandraTypes'; +import { + clearChannelThreadsTaintCacheForTesting, + syncChannelThreadsConfig, + type ThreadViewer, +} from '@app/api/experiment/ChannelThreadsGate'; +import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import {GuildThreadState} from '@app/api/Tables'; +import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; +import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants'; +import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +class MarkerCountingExecutor extends InMemoryCassandraQueryExecutor { + markerReads = 0; + + override async executeQuery, P extends CassandraParams = CassandraParams>( + query: PreparedQuery

, + ): Promise> { + if (query.cql.trimStart().toUpperCase().startsWith('SELECT') && query.cql.includes('guild_thread_state')) { + this.markerReads++; + } + return super.executeQuery(query); + } +} + +const GUILD = createGuildID(100n); +const CHANNEL = createChannelID(200n); +const USER = createUserID(10n); +const VIEWER: ThreadViewer = {kind: 'user', userId: USER, bot: false, capable: false}; + +function message(overrides: Partial = {}): MessageResponse { + return { + id: '300', + channel_id: CHANNEL.toString(), + type: MessageTypes.DEFAULT, + flags: 0, + content: 'hello', + mentions: [], + mention_roles: [], + ...overrides, + } as MessageResponse; +} + +function build(responses: Array) { + const listMessages = vi.fn(async (_params: {threadsMask?: boolean}) => responses); + const getMessage = vi.fn(async (_params: {threadsMask?: boolean}) => responses[0] ?? null); + const channelService = { + messages: { + retrieval: { + getResponseAccess: async () => ({ + access: {sourceGuildId: GUILD, messageHistoryCutoff: null, canReadMessageHistory: true}, + authChannel: {channel: {guildId: GUILD, isThreadOnly: () => false}}, + }), + threadResponses: { + shape: async (params: {responses: Array}) => params.responses, + getStarter: async () => null, + }, + }, + }, + } as unknown as ChannelService; + const service = new MessageRequestService( + channelService, + {listMessages, getMessage} as unknown as MessageResponseDataService, + {} as CrosspostSourceService, + ); + return {service, listMessages, getMessage}; +} + +function list(service: MessageRequestService) { + return service.listMessages({ + userId: USER, + viewer: VIEWER, + channelId: CHANNEL, + query: {limit: 50}, + requestCache: {} as RequestCache, + }); +} + +function get(service: MessageRequestService) { + return service.getMessage({ + userId: USER, + viewer: VIEWER, + channelId: CHANNEL, + messageId: createMessageID(300n), + requestCache: {} as RequestCache, + }); +} + +async function taint(): Promise { + await upsertOne( + GuildThreadState.upsertAll({ + guild_id: GUILD, + first_active_at: new Date(), + perms_seeded_at: null, + search_backfilled_at: null, + }), + ); +} + +describe('MessageRequestService thread masking', () => { + let executor: MarkerCountingExecutor; + + beforeEach(() => { + executor = new MarkerCountingExecutor(); + setCassandraQueryExecutorForTesting(executor); + clearChannelThreadsTaintCacheForTesting(); + const raw = JSON.stringify(ChannelThreadsConfigSchema.parse({enabled: false, ever_enabled: true})); + syncChannelThreadsConfig(raw, (value) => ChannelThreadsConfigSchema.parse(JSON.parse(value ?? '{}'))); + }); + + afterEach(() => { + syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({})); + setCassandraQueryExecutorForTesting(null); + }); + + it('never reads the marker for a page without thread data', async () => { + const {service, listMessages, getMessage} = build([message()]); + await list(service); + await get(service); + expect(executor.markerReads).toBe(0); + expect(listMessages).toHaveBeenCalledTimes(1); + expect(listMessages.mock.calls[0]![0].threadsMask).toBeUndefined(); + expect(getMessage).toHaveBeenCalledTimes(1); + expect(getMessage.mock.calls[0]![0].threadsMask).toBeUndefined(); + }); + + it('refetches masked when a tainted guild page carries thread data', async () => { + await taint(); + const {service, listMessages, getMessage} = build([message({flags: ServerMessageFlags.HAS_THREAD})]); + await list(service); + await get(service); + expect(executor.markerReads).toBe(1); + expect(listMessages.mock.calls.map(([params]) => params.threadsMask)).toEqual([undefined, true]); + expect(getMessage.mock.calls.map(([params]) => params.threadsMask)).toEqual([undefined, true]); + }); + + it('treats a mentioned forum as thread data', async () => { + await taint(); + const {service, listMessages} = build([ + message({mention_channels: [{id: '400', name: 'forum', type: ChannelTypes.GUILD_FORUM}]}), + ]); + await list(service); + expect(listMessages.mock.calls.map(([params]) => params.threadsMask)).toEqual([undefined, true]); + }); + + it('keeps raw data in an untainted guild', async () => { + const {service, listMessages} = build([message({flags: ServerMessageFlags.HAS_THREAD})]); + await list(service); + expect(executor.markerReads).toBe(1); + expect(listMessages).toHaveBeenCalledTimes(1); + }); +}); diff --git a/fluxer_api/src/api/channel/services/message/MessageRequestService.ts b/fluxer_api/src/api/channel/services/message/MessageRequestService.ts index b5c0ac1a8..8340fcb2f 100644 --- a/fluxer_api/src/api/channel/services/message/MessageRequestService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageRequestService.ts @@ -1,22 +1,39 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; +import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; import type {MessageRequest, MessageUpdateRequest} from '@app/api/channel/MessageTypes'; +import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; import type {CrosspostSourceService} from '@app/api/channel/services/message/CrosspostSourceService'; import {isPersonalNotesChannel} from '@app/api/channel/services/message/MessageHelpers'; import type {MessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService'; +import {carriesThreadArtifact, maskThreadArtifactsFor} from '@app/api/channel/services/message/ThreadMessageResponses'; +import {everEnabled, isTainted, type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {User} from '@app/api/models/User'; import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils'; +import {THREAD_FEATURE_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; import {UnclaimedAccountCannotSendMessagesError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotSendMessagesError'; import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError'; import type {CrosspostSourceResponse} from '@fluxer/schema/src/domains/message/CrosspostSourceSchemas'; import type { BulkMessageFetchResponse, + MessageChannelMentionResponse, MessageResponse, } from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +function mentionsThreadChannel(mentions: ReadonlyArray | null | undefined): boolean { + return mentions?.some((mention) => THREAD_FEATURE_CHANNEL_TYPES.has(mention.type)) ?? false; +} + +function carriesMaskableThreadData(response: MessageResponse): boolean { + if (carriesThreadArtifact(response) || mentionsThreadChannel(response.mention_channels)) return true; + if (response.message_snapshots?.some((snapshot) => mentionsThreadChannel(snapshot.mention_channels))) return true; + const referenced = response.referenced_message; + return referenced != null && carriesMaskableThreadData(referenced); +} + export class MessageRequestService { constructor( private readonly channelService: ChannelService, @@ -26,6 +43,7 @@ export class MessageRequestService { async listMessages(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; query: { limit: number; @@ -35,11 +53,14 @@ export class MessageRequestService { }; requestCache: RequestCache; }): Promise> { - const access = await this.channelService.messages.retrieval.getResponseAccessContext({ + const retrieval = this.channelService.messages.retrieval; + const {access, authChannel} = await retrieval.getResponseAccess({ + viewer: params.viewer, userId: params.userId, channelId: params.channelId, }); - return this.responseDataService.listMessages({ + if (authChannel.channel.isThreadOnly()) throw new InvalidChannelTypeError(); + const request = { userId: params.userId, channelId: params.channelId, limit: params.query.limit, @@ -47,11 +68,34 @@ export class MessageRequestService { after: params.query.after, around: params.query.around, access, + }; + let responses = await this.responseDataService.listMessages(request); + if (await this.needsThreadsMask(params.viewer, authChannel.channel.guildId, responses)) { + responses = await this.responseDataService.listMessages({...request, threadsMask: true}); + } + return retrieval.threadResponses.shape({ + viewer: params.viewer, + userId: params.userId, + authChannel, + responses, + requestCache: params.requestCache, + query: params.query, }); } + private async needsThreadsMask( + viewer: ThreadViewer, + guildId: GuildID | null, + responses: Array, + ): Promise { + if (guildId === null || !everEnabled() || viewerActive(viewer, guildId)) return false; + if (!responses.some(carriesMaskableThreadData)) return false; + return isTainted(guildId); + } + async listMessagesBulk(params: { userId: UserID; + viewer: ThreadViewer; requests: Array<{ channelId: ChannelID; query: { @@ -67,6 +111,7 @@ export class MessageRequestService { channel_id: request.channelId.toString(), messages: await this.listMessages({ userId: params.userId, + viewer: params.viewer, channelId: request.channelId, query: request.query, requestCache: params.requestCache, @@ -77,29 +122,55 @@ export class MessageRequestService { async getMessage(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; requestCache: RequestCache; }): Promise { - const access = await this.channelService.messages.retrieval.getResponseAccessContext({ + const retrieval = this.channelService.messages.retrieval; + const {access, authChannel} = await retrieval.getResponseAccess({ + viewer: params.viewer, userId: params.userId, channelId: params.channelId, messageId: params.messageId, }); - const response = await this.responseDataService.getMessage({ + const starter = await retrieval.threadResponses.getStarter({ + viewer: params.viewer, + userId: params.userId, + authChannel, + messageId: params.messageId, + requestCache: params.requestCache, + }); + if (starter) return starter; + const request = { userId: params.userId, channelId: params.channelId, messageId: params.messageId, access, - }); + }; + let response = await this.responseDataService.getMessage(request); + if (response !== null && (await this.needsThreadsMask(params.viewer, authChannel.channel.guildId, [response]))) { + response = await this.responseDataService.getMessage({...request, threadsMask: true}); + } if (response === null) { throw new UnknownMessageError(); } - return response; + const [shaped] = await retrieval.threadResponses.shape({ + viewer: params.viewer, + userId: params.userId, + authChannel, + responses: [response], + requestCache: params.requestCache, + }); + if (!shaped) { + throw new UnknownMessageError(); + } + return shaped; } async getCrosspostSource(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; requestCache: RequestCache; @@ -110,6 +181,7 @@ export class MessageRequestService { async sendMessage(params: { user: User; + viewer: ThreadViewer; channelId: ChannelID; data: MessageRequest; requestCache: RequestCache; @@ -121,53 +193,109 @@ export class MessageRequestService { throw new UnclaimedAccountCannotSendMessagesError(); } const {message, authChannel} = await this.channelService.messages.send.sendMessage({ + viewer: params.viewer, user: params.user, channelId: params.channelId, data: params.data, requestCache: params.requestCache, }); const access = await this.channelService.messages.retrieval.getResponseAccessContext({ + viewer: params.viewer, userId: params.user.id, channelId: params.channelId, authChannel, }); - return this.responseDataService.buildMessage({ + const response = await this.responseDataService.buildMessage({ userId: params.user.id, message, access: {...access, messageHistoryCutoff: null, canReadMessageHistory: true}, nonce: params.data.nonce, tts: params.data.tts ?? false, }); + const [shaped] = maskThreadArtifactsFor(params.viewer, authChannel.channel.guildId, [response]); + return shaped ?? response; } async crosspostMessage(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; requestCache: RequestCache; }): Promise { const {message, authChannel} = await this.channelService.messages.crosspost.crosspostMessage(params); - const access = await this.channelService.messages.retrieval.getResponseAccessContext({ + const retrieval = this.channelService.messages.retrieval; + const access = await retrieval.getResponseAccessContext({ + viewer: params.viewer, userId: params.userId, channelId: params.channelId, messageId: message.id, authChannel, }); - return this.responseDataService.buildMessage({ + const response = await this.responseDataService.buildMessage({ userId: params.userId, message, access, }); + const [shaped] = await retrieval.threadResponses.shape({ + viewer: params.viewer, + userId: params.userId, + authChannel, + responses: [response], + requestCache: params.requestCache, + }); + return shaped ?? response; + } + + async validateForumStarter(params: { + user: User; + parentAuth: AuthenticatedChannel; + data: MessageRequest; + }): Promise { + if (params.user.isUnclaimedAccount()) throw new UnclaimedAccountCannotSendMessagesError(); + await this.channelService.messages.send.validateForumStarter(params); + } + + async sendForumStarter(params: { + user: User; + viewer: ThreadViewer; + parentAuth: AuthenticatedChannel; + threadId: ChannelID; + data: MessageRequest; + requestCache: RequestCache; + }): Promise { + if (params.user.isUnclaimedAccount()) throw new UnclaimedAccountCannotSendMessagesError(); + const {message, authChannel} = await this.channelService.messages.send.sendMessage({ + viewer: params.viewer, + user: params.user, + channelId: params.threadId, + data: params.data, + requestCache: params.requestCache, + forumStarter: {parentAuth: params.parentAuth}, + }); + const access = await this.channelService.messages.retrieval.getResponseAccessContext({ + viewer: params.viewer, + userId: params.user.id, + channelId: params.threadId, + authChannel, + }); + return this.responseDataService.buildMessage({ + userId: params.user.id, + message, + access: {...access, messageHistoryCutoff: null, canReadMessageHistory: true}, + }); } async editMessage(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; data: MessageUpdateRequest; requestCache: RequestCache; }): Promise { const {message, authChannel} = await this.channelService.messages.edit.editMessage({ + viewer: params.viewer, userId: params.userId, channelId: params.channelId, messageId: params.messageId, @@ -175,15 +303,18 @@ export class MessageRequestService { requestCache: params.requestCache, }); const access = await this.channelService.messages.retrieval.getResponseAccessContext({ + viewer: params.viewer, userId: params.userId, channelId: params.channelId, messageId: message.id, authChannel, }); - return this.responseDataService.buildMessage({ + const response = await this.responseDataService.buildMessage({ userId: params.userId, message, access, }); + const [shaped] = maskThreadArtifactsFor(params.viewer, authChannel.channel.guildId, [response]); + return shaped ?? response; } } diff --git a/fluxer_api/src/api/channel/services/message/MessageResponseDataService.ts b/fluxer_api/src/api/channel/services/message/MessageResponseDataService.ts index 01cea0fc1..e33255a8c 100644 --- a/fluxer_api/src/api/channel/services/message/MessageResponseDataService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageResponseDataService.ts @@ -8,6 +8,7 @@ import {Logger} from '@app/api/Logger'; import type {Channel} from '@app/api/models/Channel'; import type {Message} from '@app/api/models/Message'; import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils'; +import {MessageTypes} from '@fluxer/constants/src/ChannelConstants'; import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager'; import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager'; @@ -92,8 +93,10 @@ export class MessageResponseDataService { after?: MessageID; around?: MessageID; access: MessageResponseAccessContext; + threadsMask?: boolean; }): Promise> { const response = await this.request({ + ...(params.threadsMask ? {threads_mask: true, exclude_types: [MessageTypes.THREAD_CREATED]} : {}), op: 'ListResponses', channel_id: params.channelId.toString(), viewer_user_id: params.userId.toString(), @@ -137,8 +140,10 @@ export class MessageResponseDataService { access: MessageResponseAccessContext; nonce?: string; tts?: boolean; + threadsMask?: boolean; }): Promise { const response = await this.request({ + ...(params.threadsMask ? {threads_mask: true} : {}), op: 'GetResponseById', channel_id: params.channelId.toString(), message_id: params.messageId.toString(), diff --git a/fluxer_api/src/api/channel/services/message/MessageRetrievalService.test.ts b/fluxer_api/src/api/channel/services/message/MessageRetrievalService.test.ts index 7ced23f74..ac834c608 100644 --- a/fluxer_api/src/api/channel/services/message/MessageRetrievalService.test.ts +++ b/fluxer_api/src/api/channel/services/message/MessageRetrievalService.test.ts @@ -9,6 +9,7 @@ import type {MessageChannelAuthService} from '@app/api/channel/services/message/ import type {MessageProcessingService} from '@app/api/channel/services/message/MessageProcessingService'; import {MessageRetrievalService} from '@app/api/channel/services/message/MessageRetrievalService'; import type {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService'; +import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import {Message} from '@app/api/models/Message'; import type {IUserRepository} from '@app/api/user/IUserRepository'; @@ -121,6 +122,7 @@ describe('MessageRetrievalService.getMessagesByIds', () => { }); const result = await service.getMessagesByIds({ + viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, channelId: CHANNEL_ID, messageIds: messages.map((message) => message.id), @@ -141,6 +143,7 @@ describe('MessageRetrievalService.getMessagesByIds', () => { }); const result = await service.getMessagesByIds({ + viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, channelId: CHANNEL_ID, messageIds: [beforeCutoff.id, afterCutoff.id], @@ -160,6 +163,7 @@ describe('MessageRetrievalService.getMessagesByIds', () => { }); const result = await service.getMessagesByIds({ + viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, channelId: CHANNEL_ID, messageIds: [message.id], @@ -177,6 +181,7 @@ describe('MessageRetrievalService.getMessagesByIds', () => { }); const result = await service.getMessagesByIds({ + viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, channelId: CHANNEL_ID, messageIds: [message.id], @@ -196,6 +201,7 @@ describe('MessageRetrievalService.getMessagesByIds', () => { }); const result = await service.getMessagesByIds({ + viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, channelId: CHANNEL_ID, messageIds: [present.id, deleted.id], diff --git a/fluxer_api/src/api/channel/services/message/MessageRetrievalService.ts b/fluxer_api/src/api/channel/services/message/MessageRetrievalService.ts index cec00d1c1..78ffc1848 100644 --- a/fluxer_api/src/api/channel/services/message/MessageRetrievalService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageRetrievalService.ts @@ -18,6 +18,9 @@ import { type MessageResponseAccessContext, } from '@app/api/channel/services/message/MessageResponseDataService'; import type {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService'; +import {maskThreadArtifactsFor, ThreadMessageResponses} from '@app/api/channel/services/message/ThreadMessageResponses'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; +import {maskChannelResponseThreadBits} from '@app/api/guild/services/ThreadPermissionBits'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {Channel} from '@app/api/models/Channel'; @@ -65,17 +68,40 @@ export class MessageRetrievalService { return this.isMessageAfterCutoff(messageId, cutoff); } + private threadResponsesInstance: ThreadMessageResponses | undefined; + + get threadResponses(): ThreadMessageResponses { + this.threadResponsesInstance ??= new ThreadMessageResponses( + this.channelRepository, + createMessageResponseDataService(), + this.userCacheService, + ); + return this.threadResponsesInstance; + } + async getResponseAccessContext(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId?: MessageID; authChannel?: AuthenticatedChannel; }): Promise { + return (await this.getResponseAccess(params)).access; + } + + async getResponseAccess(params: { + userId: UserID; + viewer: ThreadViewer; + channelId: ChannelID; + messageId?: MessageID; + authChannel?: AuthenticatedChannel; + }): Promise<{access: MessageResponseAccessContext; authChannel: AuthenticatedChannel}> { const authChannel = params.authChannel ?? (await this.channelAuthService.getChannelAuthenticated({ userId: params.userId, channelId: params.channelId, + viewer: params.viewer, })); if (params.messageId && !(await this.canAccessMessage(authChannel, params.messageId))) { throw new UnknownMessageError(); @@ -83,22 +109,27 @@ export class MessageRetrievalService { const canReadMessageHistory = !authChannel.guild || (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY)); return { - sourceGuildId: authChannel.channel.guildId, - messageHistoryCutoff: canReadMessageHistory ? null : (authChannel.guild?.message_history_cutoff ?? null), - canReadMessageHistory, + authChannel, + access: { + sourceGuildId: authChannel.channel.guildId, + messageHistoryCutoff: canReadMessageHistory ? null : (authChannel.guild?.message_history_cutoff ?? null), + canReadMessageHistory, + }, }; } async getMessage({ userId, + viewer, channelId, messageId, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; }): Promise { - const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId, viewer}); if (!(await this.canAccessMessage(authChannel, messageId))) { throw new UnknownMessageError(); } @@ -113,14 +144,16 @@ export class MessageRetrievalService { async getMessagesByIds({ userId, + viewer, channelId, messageIds, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageIds: Array; }): Promise> { - const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId, viewer}); const canReadMessageHistory = !authChannel.guild || (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY)); const cutoff = authChannel.guild?.message_history_cutoff ?? null; @@ -141,16 +174,18 @@ export class MessageRetrievalService { async searchMessages({ userId, + viewer, channelId, searchParams, requestCache, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; searchParams: MessageSearchRequest; requestCache: RequestCache; }): Promise { - const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId}); + const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId, viewer}); const {channel} = authChannel; const hasReadHistory = !authChannel.guild || (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY)); if (!hasReadHistory) { @@ -208,11 +243,12 @@ export class MessageRetrievalService { messages: result.messages, access, }); - const messageResponses = builtMessages.map( + const messageResponses = maskThreadArtifactsFor(viewer, channel.guildId, builtMessages).map( ({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage, ); return { - channels: messageResponses.length > 0 ? [await this.mapSearchChannelResponse(channel, userId, requestCache)] : [], + channels: + messageResponses.length > 0 ? await this.mapSearchChannelResponse(channel, userId, viewer, requestCache) : [], messages: messageResponses, total: hasReadHistory ? result.total : messageResponses.length, hits_per_page: hitsPerPage, @@ -220,13 +256,19 @@ export class MessageRetrievalService { }; } - private async mapSearchChannelResponse(channel: Channel, userId: UserID, requestCache: RequestCache) { - return mapChannelToResponse({ + private async mapSearchChannelResponse( + channel: Channel, + userId: UserID, + viewer: ThreadViewer, + requestCache: RequestCache, + ) { + const response = await mapChannelToResponse({ channel, currentUserId: userId, userCacheService: this.userCacheService, requestCache, }); + return maskChannelResponseThreadBits(channel.guildId, viewer, [response]); } private async channelNeedsIndexing(channel: Channel, channelId: ChannelID): Promise { diff --git a/fluxer_api/src/api/channel/services/message/MessageSearchService.ts b/fluxer_api/src/api/channel/services/message/MessageSearchService.ts index b13e88e75..947b3d6df 100644 --- a/fluxer_api/src/api/channel/services/message/MessageSearchService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageSearchService.ts @@ -8,6 +8,7 @@ import type {IMessageSearchService} from '@app/api/search/IMessageSearchService' import {deleteMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; +import {MessageTypes} from '@fluxer/constants/src/ChannelConstants'; import type {MessageSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes'; import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas'; import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService'; @@ -26,6 +27,10 @@ function getMessageIndexServices(options: MessageSearchIndexOptions = {}): Array return services; } +export function isMessageSearchIndexable(message: Message): boolean { + return message.type !== MessageTypes.THREAD_CREATED && message.type !== MessageTypes.THREAD_STARTER_MESSAGE; +} + export class MessageSearchService { constructor( private userRepository: IUserRepository, @@ -33,6 +38,7 @@ export class MessageSearchService { ) {} async indexMessage(message: Message, authorIsBot: boolean, options?: MessageSearchIndexOptions): Promise { + if (!isMessageSearchIndexable(message)) return; try { const searchServices = getMessageIndexServices(options); if (searchServices.length === 0) { @@ -54,6 +60,7 @@ export class MessageSearchService { } async updateMessageIndex(message: Message, options?: MessageSearchIndexOptions): Promise { + if (!isMessageSearchIndexable(message)) return; try { const searchServices = getMessageIndexServices(options); if (searchServices.length === 0) { diff --git a/fluxer_api/src/api/channel/services/message/MessageSendService.ts b/fluxer_api/src/api/channel/services/message/MessageSendService.ts index 44341ab99..13d48b29f 100644 --- a/fluxer_api/src/api/channel/services/message/MessageSendService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageSendService.ts @@ -2,6 +2,7 @@ import type {AttachmentID, ChannelID, GuildID, MessageID, RoleID, UserID} from '@app/api/BrandedTypes'; import { + channelIdToMessageId, createAttachmentID, createChannelID, createGuildID, @@ -35,8 +36,12 @@ import type {MessageProcessingService} from '@app/api/channel/services/message/M import type {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService'; import type {MessageValidationService} from '@app/api/channel/services/message/MessageValidationService'; import type {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock'; +import type {ThreadMessageActivity} from '@app/api/channel/services/message/ThreadMessageActivity'; +import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials'; +import {enqueueThreadSearchSync} from '@app/api/channel/threads/ThreadJobs'; import {SYSTEM_USER_ID} from '@app/api/constants/Core'; import type {MessageAttachment, MessageReference} from '@app/api/database/types/MessageTypes'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository'; import type {GatewayChannelMention, IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; @@ -63,6 +68,7 @@ import { SENDABLE_MESSAGE_FLAGS, } from '@fluxer/constants/src/ChannelConstants'; import {GuildNSFWLevel, GuildOperations} from '@fluxer/constants/src/GuildConstants'; +import {threadWriteBlock} from '@fluxer/constants/src/ThreadPermissionUtils'; import { DELETED_USER_ID, RelationshipTypes, @@ -83,6 +89,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService'; interface MessageSendServiceDeps { + threadActivity: ThreadMessageActivity; channelRepository: IChannelRepositoryAggregate; userRepository: IUserRepository; storageService: IStorageService; @@ -303,24 +310,120 @@ export class MessageSendService { return {canEmbedLinks, canMentionEveryone, canAttachFiles}; } + private assertThreadSendAllowed(authChannel: AuthenticatedChannel): void { + if (authChannel.thread) { + assertThreadAllowed(threadWriteBlock('send', authChannel.thread.actor)); + } + } + + private assertForwardableReference(isForwardMessage: boolean, referencedMessage: Message | null): void { + if (isForwardMessage && referencedMessage?.type === MessageTypes.THREAD_CREATED) { + throw InputValidationError.fromCode('message_reference', ValidationErrorCodes.CANNOT_REPLY_TO_SYSTEM_MESSAGE); + } + } + + private async generateMessageId(channel: Channel): Promise { + let messageId = createMessageID(await this.deps.snowflakeService.generateForChannel(channel.id)); + for (let attempt = 0; channel.isThread() && BigInt(messageId) <= BigInt(channel.id) && attempt < 3; attempt++) { + messageId = createMessageID(await this.deps.snowflakeService.generateForChannel(channel.id)); + } + if (channel.isThread() && BigInt(messageId) <= BigInt(channel.id)) { + throw new Error('Thread message id must be greater than the thread id'); + } + return messageId; + } + + async validateForumStarter({ + user, + parentAuth, + data, + }: { + user: User; + parentAuth: AuthenticatedChannel; + data: MessageRequest; + }): Promise { + if (!user.isBot && user.id !== SYSTEM_USER_ID && !(user.flags & UserFlags.HAS_SESSION_STARTED)) { + throw InputValidationError.fromCode('content', ValidationErrorCodes.MUST_START_SESSION_BEFORE_SENDING); + } + const {channel, guild, member, checkPermission, hasPermission} = parentAuth; + await this.checkMessageSendPermissions({guild, member, channel, data, user, checkPermission, hasPermission}); + this.ensureMessageRequestIsValid({user, data, guildFeatures: guild?.features ?? null}); + this.deps.embedAttachmentResolver.validateAttachmentReferences({ + embeds: data.embeds, + attachments: data.attachments, + }); + await this.ensureAttachmentsExist({ + attachments: data.attachments, + user, + channelId: channel.id, + guildFeatures: guild?.features ?? null, + }); + } + + async validateWebhookForumStarter({parent, data}: {parent: Channel; data: MessageRequest}): Promise { + if (!parent.guildId) throw new CannotExecuteOnDmError(); + const guild = await this.deps.gatewayService.getGuildData({ + guildId: parent.guildId, + userId: createUserID(0n), + skipMembershipCheck: true, + }); + this.ensureWebhookMessageRequestIsValid(data, guild?.features ?? null); + this.deps.embedAttachmentResolver.validateAttachmentReferences({ + embeds: data.embeds, + attachments: data.attachments, + }); + } + + private ensureWebhookMessageRequestIsValid(data: MessageRequest, guildFeatures: Iterable | null): boolean { + const isForwardMessage = data.message_reference?.type === MessageReferenceTypes.FORWARD; + if (isForwardMessage) { + if (!data.message_reference?.channel_id || !data.message_reference?.message_id) { + throw InputValidationError.fromCode( + 'message_reference', + ValidationErrorCodes.FORWARD_REFERENCE_REQUIRES_CHANNEL_AND_MESSAGE, + ); + } + if ( + data.content || + (data.embeds && data.embeds.length > 0) || + (data.attachments && data.attachments.length > 0) || + (data.sticker_ids && data.sticker_ids.length > 0) + ) { + throw InputValidationError.fromCode( + 'message_reference', + ValidationErrorCodes.FORWARD_MESSAGES_CANNOT_CONTAIN_CONTENT, + ); + } + } else { + this.deps.validationService.validateMessageContent(data, null, { + guildFeatures, + messageAuthorType: 'webhook', + }); + } + return isForwardMessage; + } + async validateMessageCanBeSent({ user, + viewer, channelId, data, }: { user: User; + viewer: ThreadViewer; channelId: ChannelID; data: MessageRequest; }): Promise { const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({ userId: user.id, + viewer, channelId, }); if (!user.isBot && user.id !== SYSTEM_USER_ID && !(user.flags & UserFlags.HAS_SESSION_STARTED)) { throw InputValidationError.fromCode('content', ValidationErrorCodes.MUST_START_SESSION_BEFORE_SENDING); } if (isPersonalNotesChannel({userId: user.id, channelId})) { - await this.validatePersonalNoteMessage({user, channelId, data}); + await this.validatePersonalNoteMessage({user, viewer, channelId, data}); return; } const {channel, guild, checkPermission, hasPermission, member} = authChannel; @@ -334,6 +437,7 @@ export class MessageSendService { hasPermission, }); this.deps.validationService.ensureTextChannel(channel); + this.assertThreadSendAllowed(authChannel); const isForwardMessage = this.ensureMessageRequestIsValid({user, data, guildFeatures: guild?.features ?? null}); this.deps.embedAttachmentResolver.validateAttachmentReferences({ embeds: data.embeds, @@ -342,9 +446,12 @@ export class MessageSendService { const {referencedMessage, referencedChannelGuildId} = await this.fetchReferencedMessageForValidation({ data, channelId, + channelIsThread: channel.isThread(), isForwardMessage, user, + viewer, }); + this.assertForwardableReference(isForwardMessage, referencedMessage); if (isForwardMessage && referencedMessage && guild) { const hasEmbeds = (referencedMessage.flags & MessageFlags.SUPPRESS_EMBEDS) === 0 && referencedMessage.embeds.length > 0; @@ -409,15 +516,18 @@ export class MessageSendService { private async validatePersonalNoteMessage({ user, + viewer, channelId, data, }: { user: User; + viewer: ThreadViewer; channelId: ChannelID; data: MessageRequest; }): Promise { const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({ userId: user.id, + viewer, channelId, }); const {channel} = authChannel; @@ -430,8 +540,10 @@ export class MessageSendService { const {referencedMessage, referencedChannelGuildId} = await this.fetchReferencedMessageForValidation({ data, channelId, + channelIsThread: channel.isThread(), isForwardMessage, user, + viewer, }); if (data.message_reference && referencedMessage && !isForwardMessage) { const replyableTypes: ReadonlySet = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]); @@ -477,13 +589,17 @@ export class MessageSendService { private async fetchReferencedMessageForValidation({ data, channelId, + channelIsThread, isForwardMessage, user, + viewer, }: { data: MessageRequest; channelId: ChannelID; + channelIsThread: boolean; isForwardMessage: boolean; user: User; + viewer: ThreadViewer; }): Promise<{ referencedMessage: Message | null; referencedChannelGuildId?: GuildID | null; @@ -497,6 +613,7 @@ export class MessageSendService { if (isForwardMessage) { forwardReferenceAuthChannel = await this.deps.channelAuthService.getChannelAuthenticated({ userId: user.id, + viewer, channelId: createChannelID(data.message_reference.channel_id!), }); await this.ensureForwardSourceAccess(forwardReferenceAuthChannel); @@ -508,11 +625,26 @@ export class MessageSendService { createMessageID(data.message_reference.message_id), ); if (!referencedMessage) { + this.assertNotThreadStarterReference( + forwardReferenceAuthChannel?.channel.isThread() ?? channelIsThread, + referenceChannelId, + data.message_reference.message_id, + ); throw new UnknownMessageError(); } return {referencedMessage, referencedChannelGuildId}; } + private assertNotThreadStarterReference( + referenceIsThread: boolean, + referenceChannelId: ChannelID, + messageId: bigint | string, + ): void { + if (referenceIsThread && String(messageId) === referenceChannelId.toString()) { + throw InputValidationError.fromCode('message_reference', ValidationErrorCodes.CANNOT_REPLY_TO_SYSTEM_MESSAGE); + } + } + private async ensureAttachmentsExist({ attachments, user, @@ -598,13 +730,17 @@ export class MessageSendService { private async resolveReferenceContext({ data, channelId, + channelIsThread, isForwardMessage, user, + viewer, }: { data: MessageRequest; channelId: ChannelID; + channelIsThread: boolean; isForwardMessage: boolean; user: User; + viewer: ThreadViewer; }): Promise<{ referencedMessage: Message | null; referencedChannelGuildId?: GuildID | null; @@ -616,6 +752,7 @@ export class MessageSendService { if (isForwardMessage) { forwardReferenceAuthChannel = await this.deps.channelAuthService.getChannelAuthenticated({ userId: user.id, + viewer, channelId: createChannelID(data.message_reference!.channel_id!), }); await this.ensureForwardSourceAccess(forwardReferenceAuthChannel); @@ -629,6 +766,11 @@ export class MessageSendService { ) : null; if (data.message_reference && !referencedMessage) { + this.assertNotThreadStarterReference( + forwardReferenceAuthChannel?.channel.isThread() ?? channelIsThread, + referenceChannelId, + data.message_reference.message_id, + ); throw new UnknownMessageError(); } let messageSnapshots: Array | undefined; @@ -770,28 +912,35 @@ export class MessageSendService { async sendMessage({ user, + viewer, channelId, data, requestCache, + forumStarter, }: { user: User; + viewer: ThreadViewer; channelId: ChannelID; data: MessageRequest; requestCache: RequestCache; + forumStarter?: {parentAuth: AuthenticatedChannel}; }): Promise { const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({ userId: user.id, + viewer, channelId, }); if (!user.isBot && user.id !== SYSTEM_USER_ID && !(user.flags & UserFlags.HAS_SESSION_STARTED)) { throw InputValidationError.fromCode('content', ValidationErrorCodes.MUST_START_SESSION_BEFORE_SENDING); } if (isPersonalNotesChannel({userId: user.id, channelId})) { - const message = await this.sendPersonalNoteMessage({authChannel, user, channelId, data, requestCache}); + const message = await this.sendPersonalNoteMessage({authChannel, user, viewer, channelId, data, requestCache}); return {message, authChannel}; } assertAccountNotLimited(user); - const {channel, guild, checkPermission, hasPermission, member} = authChannel; + const {channel, guild, member} = authChannel; + const {checkPermission, hasPermission} = forumStarter?.parentAuth ?? authChannel; + const uploadChannelId = forumStarter?.parentAuth.channel.id ?? channelId; const {canEmbedLinks, canMentionEveryone, canAttachFiles} = await this.checkMessageSendPermissions({ guild, member, @@ -801,10 +950,12 @@ export class MessageSendService { checkPermission, hasPermission, }); - const needsSlowmodeCheck = guild && channel.rateLimitPerUser && channel.rateLimitPerUser > 0 && !user.isBot; + const needsSlowmodeCheck = + !forumStarter && guild && channel.rateLimitPerUser && channel.rateLimitPerUser > 0 && !user.isBot; const slowmodeBypass = needsSlowmodeCheck ? await hasPermission(Permissions.BYPASS_SLOWMODE) : false; const slowmodeKey = needsSlowmodeCheck && !slowmodeBypass ? `slowmode:${channelId}:${user.id}` : null; this.deps.validationService.ensureTextChannel(channel); + this.assertThreadSendAllowed(authChannel); const isForwardMessage = this.ensureMessageRequestIsValid({user, data, guildFeatures: guild?.features ?? null}); this.deps.embedAttachmentResolver.validateAttachmentReferences({ embeds: data.embeds, @@ -821,10 +972,13 @@ export class MessageSendService { const referenceContext = await this.resolveReferenceContext({ data, channelId, + channelIsThread: channel.isThread(), isForwardMessage, user, + viewer, }); const {referencedMessage, referencedChannelGuildId, messageSnapshots} = referenceContext; + this.assertForwardableReference(isForwardMessage, referencedMessage); if (isForwardMessage && messageSnapshots && guild) { const snapshotHasEmbeds = messageSnapshots.some((s) => s.embeds.length > 0); const snapshotHasAttachments = messageSnapshots.some((s) => s.attachments.length > 0); @@ -837,7 +991,7 @@ export class MessageSendService { } if (isForwardMessage && messageSnapshots && this.snapshotsContainNsfwContent(messageSnapshots)) { const guildNsfw = guild != null && guild.nsfw_level === GuildNSFWLevel.AGE_RESTRICTED; - const destAllowsNsfw = channel.isNsfw || guildNsfw; + const destAllowsNsfw = (authChannel.thread?.parent ?? channel).isNsfw || guildNsfw; if (!destAllowsNsfw) { throw new NsfwContentRequiresAgeVerificationError(); } @@ -871,7 +1025,7 @@ export class MessageSendService { await this.ensureAttachmentsExist({ attachments: data.attachments, user, - channelId, + channelId: uploadChannelId, guildFeatures: guild?.features ?? null, }); const {attachmentsToProcess, favoriteMemeAttachment} = await this.prepareMessageAttachments({ @@ -880,7 +1034,7 @@ export class MessageSendService { data, }); const dmNsfwContext = guild ? undefined : await this.buildDmNsfwContext(channel, user.id); - const messageId = createMessageID(await this.deps.snowflakeService.generateForChannel(channelId)); + const messageId = forumStarter ? channelIdToMessageId(channel.id) : await this.generateMessageId(channel); let mentionData: SendMentionData | undefined; const shouldExtractMentions = channel && !isForwardMessage && (data.content !== undefined || data.message_reference != null); @@ -941,6 +1095,16 @@ export class MessageSendService { }); } } + if (authChannel.thread && !forumStarter) { + await this.deps.threadActivity.beforeUserSend({ + channel, + parent: authChannel.thread.parent, + state: authChannel.thread.state, + member: authChannel.thread.member, + userId: user.id, + isBot: user.isBot, + }); + } const suppressDmRecipientDelivery = dmRecipientId !== null && isDirectDeliverySuppressed(user); const suppressDelivery = suppressDmRecipientDelivery || isContentHidden(user, messageId); const channelHadMessages = channel.lastMessageId !== null; @@ -954,6 +1118,7 @@ export class MessageSendService { embeds: data.embeds, attachments: attachmentsToProcess, attachmentUploadUserId: user.id, + uploadChannelId, processedAttachments: favoriteMemeAttachment ? [favoriteMemeAttachment] : undefined, stickerIds: data.sticker_ids ? data.sticker_ids.flatMap((stickerId) => createStickerID(stickerId)) : undefined, messageReference, @@ -968,6 +1133,7 @@ export class MessageSendService { mentionData, allowEmbeds: canEmbedLinks, dmNsfwContext, + threadInsert: authChannel.thread !== undefined, }); this.cacheMentionChannels({ requestCache, @@ -996,6 +1162,20 @@ export class MessageSendService { step: 'update_read_states', promise: this.deps.processingService.updateReadStates({user, guild, channel, channelId, messageId}), }, + ...(authChannel.thread && mentionData && mentionData.mentionUserIds.length > 0 + ? [ + { + step: 'thread_mention_members', + promise: this.deps.threadActivity.addMentionedUsers({ + channel, + parent: authChannel.thread.parent, + isModerator: authChannel.thread.isModerator, + authorId: user.id, + mentionUserIds: mentionData.mentionUserIds, + }), + }, + ] + : []), ]); } await this.settlePostCreateWork(messageId, [ @@ -1038,6 +1218,7 @@ export class MessageSendService { void enqueueDeferredEmbeds().catch((error) => { Logger.warn({error, messageId: messageId.toString()}, 'Failed to enqueue deferred embed extraction'); }); + if (authChannel.thread) enqueueThreadSearchSync(channel.id, {activity: true}); const searchIndexOptions = this.getSearchIndexOptions(channel); if (searchIndexOptions && !suppressDmRecipientDelivery) { void this.deps.searchService.indexMessage(message, user.isBot, searchIndexOptions); @@ -1047,19 +1228,23 @@ export class MessageSendService { async sendWebhookMessage({ webhook, + thread, data, username, avatar, requestCache, + forumStarter, }: { webhook: Webhook; + thread?: Channel | null; data: MessageRequest; username?: string | null; avatar?: string | null; requestCache: RequestCache; + forumStarter?: boolean; }): Promise { - const channelId = webhook.channelId!; - const channel = await this.deps.channelRepository.channelData.findUnique(channelId); + const channelId = thread?.id ?? webhook.channelId!; + const channel = thread ?? (await this.deps.channelRepository.channelData.findUnique(channelId)); if (!channel?.guildId) { throw new CannotExecuteOnDmError(); } @@ -1068,31 +1253,7 @@ export class MessageSendService { userId: createUserID(0n), skipMembershipCheck: true, }); - const isForwardMessage = data.message_reference?.type === MessageReferenceTypes.FORWARD; - if (isForwardMessage) { - if (!data.message_reference?.channel_id || !data.message_reference?.message_id) { - throw InputValidationError.fromCode( - 'message_reference', - ValidationErrorCodes.FORWARD_REFERENCE_REQUIRES_CHANNEL_AND_MESSAGE, - ); - } - if ( - data.content || - (data.embeds && data.embeds.length > 0) || - (data.attachments && data.attachments.length > 0) || - (data.sticker_ids && data.sticker_ids.length > 0) - ) { - throw InputValidationError.fromCode( - 'message_reference', - ValidationErrorCodes.FORWARD_MESSAGES_CANNOT_CONTAIN_CONTENT, - ); - } - } else { - this.deps.validationService.validateMessageContent(data, null, { - guildFeatures: guild?.features ?? null, - messageAuthorType: 'webhook', - }); - } + const isForwardMessage = this.ensureWebhookMessageRequestIsValid(data, guild?.features ?? null); this.deps.embedAttachmentResolver.validateAttachmentReferences({ embeds: data.embeds, attachments: data.attachments, @@ -1136,6 +1297,7 @@ export class MessageSendService { throw InputValidationError.fromCode('message_reference', ValidationErrorCodes.CANNOT_REPLY_TO_SYSTEM_MESSAGE); } } + this.assertForwardableReference(isForwardMessage, referencedMessage); } const messageReference = this.buildMessageReferencePayload({ data, @@ -1144,7 +1306,10 @@ export class MessageSendService { isForwardMessage, referencedChannelGuildId: channel.guildId, }); - const messageId = createMessageID(await this.deps.snowflakeService.generateForChannel(channelId)); + if (thread && !forumStarter) { + await this.deps.threadActivity.beforeWebhookSend(thread); + } + const messageId = thread && forumStarter ? channelIdToMessageId(thread.id) : await this.generateMessageId(channel); let mentionData: SendMentionData | undefined; const shouldExtractWebhookMentions = channel && !isForwardMessage && (data.content !== undefined || data.message_reference != null); @@ -1194,6 +1359,7 @@ export class MessageSendService { embeds: data.embeds, attachments: this.attachmentsToProcess(data.attachments), attachmentUploadUserId: await this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments), + uploadChannelId: thread ? (webhook.channelId ?? undefined) : undefined, stickerIds: data.sticker_ids ? data.sticker_ids.flatMap((stickerId) => createStickerID(stickerId)) : undefined, messageReference, messageSnapshots, @@ -1203,6 +1369,7 @@ export class MessageSendService { referencedMessage, mentionData, allowEmbeds: true, + threadInsert: thread != null, }); this.cacheMentionChannels({ requestCache, @@ -1226,6 +1393,7 @@ export class MessageSendService { void enqueueDeferredEmbeds().catch((error) => { Logger.warn({error, messageId: messageId.toString()}, 'Failed to enqueue deferred embed extraction'); }); + if (thread) enqueueThreadSearchSync(thread.id, {activity: true}); const searchIndexOptions = this.getSearchIndexOptions(channel); if (searchIndexOptions) { void this.deps.searchService.indexMessage(message, false, searchIndexOptions); @@ -1235,17 +1403,19 @@ export class MessageSendService { async editWebhookMessage({ webhook, + thread, messageId, data, requestCache, }: { webhook: Webhook; + thread?: Channel | null; messageId: MessageID; data: MessageUpdateRequest; requestCache: RequestCache; }): Promise { - const channelId = webhook.channelId!; - const channel = await this.deps.channelRepository.channelData.findUnique(channelId); + const channelId = thread?.id ?? webhook.channelId!; + const channel = thread ?? (await this.deps.channelRepository.channelData.findUnique(channelId)); if (!channel?.guildId) { throw new CannotExecuteOnDmError(); } @@ -1254,6 +1424,9 @@ export class MessageSendService { if (existingMessage.webhookId !== webhook.id) { throw new MissingPermissionsError(); } + if (thread) { + await this.deps.threadActivity.assertWebhookCanEdit(thread); + } const guild = await this.deps.gatewayService.getGuildData({ guildId: channel.guildId, userId: createUserID(0n), @@ -1309,12 +1482,14 @@ export class MessageSendService { private async sendPersonalNoteMessage({ authChannel, user, + viewer, channelId, data, requestCache, }: { authChannel: AuthenticatedChannel; user: User; + viewer: ThreadViewer; channelId: ChannelID; data: MessageRequest; requestCache: RequestCache; @@ -1336,8 +1511,10 @@ export class MessageSendService { const {referencedMessage, referencedChannelGuildId, messageSnapshots} = await this.resolveReferenceContext({ data, channelId, + channelIsThread: channel.isThread(), isForwardMessage, user, + viewer, }); this.ensureForwardGuildMatches({data, referencedChannelGuildId}); await this.ensureAttachmentsExist({ diff --git a/fluxer_api/src/api/channel/services/message/MessageValidationService.ts b/fluxer_api/src/api/channel/services/message/MessageValidationService.ts index 5ae79fd24..b903cbe98 100644 --- a/fluxer_api/src/api/channel/services/message/MessageValidationService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageValidationService.ts @@ -30,6 +30,7 @@ import { MAX_MESSAGE_LENGTH_PREMIUM, MAX_VOICE_MESSAGE_DURATION, } from '@fluxer/constants/src/LimitConstants'; +import {THREAD_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {CannotEditSystemMessageError} from '@fluxer/errors/src/domains/channel/CannotEditSystemMessageError'; import {CannotSendEmptyMessageError} from '@fluxer/errors/src/domains/channel/CannotSendEmptyMessageError'; @@ -46,7 +47,7 @@ export class MessageValidationService { ) {} ensureTextChannel(channel: Channel): void { - if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) { + if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type) && !THREAD_CHANNEL_TYPES.has(channel.type)) { throw new CannotSendMessageToNonTextChannelError(); } } diff --git a/fluxer_api/src/api/channel/services/message/ThreadMessageActivity.ts b/fluxer_api/src/api/channel/services/message/ThreadMessageActivity.ts new file mode 100644 index 000000000..7d6dc3b08 --- /dev/null +++ b/fluxer_api/src/api/channel/services/message/ThreadMessageActivity.ts @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {UserID} from '@app/api/BrandedTypes'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import { + dispatchThreadEvents, + type ThreadDispatchEvent, + threadMembersUpdateEvent, + threadMemberUpdateEvent, + threadUpdateEvent, +} from '@app/api/channel/services/thread/ThreadDispatch'; +import type {ThreadView} from '@app/api/channel/services/thread/ThreadMappers'; +import {loadThreadView} from '@app/api/channel/services/thread/ThreadViews'; +import {threadMemberAutojoinTotal} from '@app/api/channel/threads/ThreadMetrics'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import {getKVThreadAutoArchiveQueue} from '@app/api/middleware/ServiceSingletons'; +import type {Channel} from '@app/api/models/Channel'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import {addThreadMembersWithinCap, threadRecipients} from '@app/api/worker/tasks/ThreadMentionScope'; +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import { + MAX_ACTIVE_THREADS_PER_GUILD, + MAX_THREAD_MEMBERS, + ThreadMemberFlags, +} from '@fluxer/constants/src/ThreadConstants'; +import {MaxActiveThreadsError} from '@fluxer/errors/src/domains/channel/MaxActiveThreadsError'; +import {ThreadArchivedError} from '@fluxer/errors/src/domains/channel/ThreadArchivedError'; +import {ThreadLockedError} from '@fluxer/errors/src/domains/channel/ThreadLockedError'; +import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; + +const INTERACTED_CAS_ATTEMPTS = 3; + +export class ThreadMessageActivity { + constructor( + private readonly channelRepository: IChannelRepositoryAggregate, + private readonly gatewayService: IGatewayService, + private readonly userRepository: IUserRepository, + ) {} + + async assertCanUnarchive(state: ThreadState): Promise { + if (!state.archived) return; + if ((await this.channelRepository.threads.countActiveThreads(state.guildId)) >= MAX_ACTIVE_THREADS_PER_GUILD) { + throw new MaxActiveThreadsError(MAX_ACTIVE_THREADS_PER_GUILD); + } + } + + async unarchive(channel: Channel, state: ThreadState): Promise { + if (!state.archived) return state; + await this.assertCanUnarchive(state); + const transition = await this.channelRepository.threads.updateState(state.threadId, (current) => + current.archived ? {archived: false} : null, + ); + if (!transition) throw new UnknownChannelError(); + if (transition.previous.archived && !transition.state.archived) { + await getKVThreadAutoArchiveQueue().schedule(transition.state, channel.lastMessageId); + } + return transition.state; + } + + private allMembers(thread: Channel): Promise> { + return this.channelRepository.threads.listMembers(thread.id, {limit: MAX_THREAD_MEMBERS}); + } + + async beforeWebhookSend(thread: Channel): Promise { + const state = await this.channelRepository.threads.getState(thread.id); + if (!state || state.guildId !== thread.guildId) throw new UnknownChannelError(); + if (state.locked) throw new ThreadLockedError(); + const next = await this.unarchive(thread, state); + if (next !== state) { + await this.dispatch(thread, [threadUpdateEvent(await this.view(thread, next), await this.allMembers(thread))]); + } + } + + async assertWebhookCanEdit(thread: Channel): Promise { + const state = await this.channelRepository.threads.getState(thread.id); + if (!state) throw new UnknownChannelError(); + if (state.archived) throw new ThreadArchivedError(); + if (state.locked) throw new ThreadLockedError(); + } + + async beforeUserSend(params: { + channel: Channel; + parent: Channel; + state: ThreadState; + member: ThreadMember | null; + userId: UserID; + isBot: boolean; + }): Promise { + const {channel, member, userId} = params; + const state = await this.unarchive(channel, params.state); + const unarchived = state !== params.state; + const events: Array = []; + const updated = member === null ? null : await this.markInteracted(member); + if (unarchived) { + events.push(threadUpdateEvent(await this.view(channel, state, params.parent), await this.allMembers(channel))); + } else if (updated) { + events.push(threadMemberUpdateEvent(updated)); + } + if (member === null) { + const result = params.isBot + ? null + : await addThreadMembersWithinCap(this.channelRepository.threads, state, [ + {userId, flags: ThreadMemberFlags.HAS_INTERACTED}, + ]); + if (result && result.added.length > 0) { + threadMemberAutojoinTotal.inc('source="send"'); + events.push( + threadMembersUpdateEvent(await this.view(channel, result.state, params.parent), {added: result.added}), + ); + } + } + await this.dispatch(channel, events); + } + + private async markInteracted(loaded: ThreadMember): Promise { + let member: ThreadMember | null = loaded; + for (let attempt = 0; attempt < INTERACTED_CAS_ATTEMPTS; attempt++) { + if (!member || (member.flags & ThreadMemberFlags.HAS_INTERACTED) !== 0) return null; + const updated = await this.channelRepository.threads.updateMemberSettings(member, { + flags: member.flags | ThreadMemberFlags.HAS_INTERACTED, + }); + if (updated) return updated; + member = await this.channelRepository.threads.getMember(loaded.threadId, loaded.userId); + } + return null; + } + + async addMentionedUsers(params: { + channel: Channel; + parent: Channel; + isModerator: boolean; + authorId: UserID; + mentionUserIds: Array; + }): Promise { + const {channel, parent} = params; + const guildId = channel.guildId; + if (guildId === null) return; + const mentioned = [...new Set(params.mentionUserIds)].filter((userId) => userId !== params.authorId); + if (mentioned.length === 0) return; + const recipients = await threadRecipients(this.userRepository, guildId, mentioned); + const candidates = mentioned.filter((userId) => recipients.has(userId)); + if (candidates.length === 0) return; + const state = await this.channelRepository.threads.getState(channel.id); + if (!state || state.archived) return; + if (state.isPrivate && !params.isModerator && state.invitable === false) return; + const existing = new Set( + (await this.channelRepository.threads.getMembers(channel.id, candidates)).map((member) => member.userId), + ); + const visible = await Promise.all( + candidates + .filter((userId) => !existing.has(userId)) + .map(async (userId) => + (await this.gatewayService.checkPermission({ + guildId, + userId, + channelId: parent.id, + permission: Permissions.VIEW_CHANNEL, + })) + ? userId + : null, + ), + ); + const toAdd = visible.filter((userId): userId is UserID => userId !== null); + if (toAdd.length === 0) return; + const result = await addThreadMembersWithinCap( + this.channelRepository.threads, + state, + toAdd.map((userId) => ({userId, flags: 0})), + ); + if (!result || result.added.length === 0) return; + threadMemberAutojoinTotal.inc('source="mention"', result.added.length); + await this.dispatch(channel, [ + threadMembersUpdateEvent(await this.view(channel, result.state, parent), {added: result.added}), + ]); + } + + async view(channel: Channel, state: ThreadState, parent?: Channel): Promise { + const parentChannel = parent ?? (await this.channelRepository.channelData.findUnique(state.parentId)); + if (parentChannel) return loadThreadView(this.channelRepository, channel, state, parentChannel); + return {channel, state, stats: await this.channelRepository.threads.getStats(state.threadId), parentType: null}; + } + + private async dispatch(channel: Channel, events: Array): Promise { + if (events.length === 0 || channel.guildId === null) return; + await dispatchThreadEvents(this.gatewayService, channel.guildId, events); + } +} diff --git a/fluxer_api/src/api/channel/services/message/ThreadMessageResponses.ts b/fluxer_api/src/api/channel/services/message/ThreadMessageResponses.ts new file mode 100644 index 000000000..0d2e5ec3e --- /dev/null +++ b/fluxer_api/src/api/channel/services/message/ThreadMessageResponses.ts @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; +import {channelIdToMessageId, createChannelID, createMessageID} from '@app/api/BrandedTypes'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; +import { + type MessageResponseDataService, + messageResponseAccessForGuild, +} from '@app/api/channel/services/message/MessageResponseDataService'; +import {mapThreadToResponse} from '@app/api/channel/services/thread/ThreadMappers'; +import {type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate'; +import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; +import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import type {Channel} from '@app/api/models/Channel'; +import type {Message} from '@app/api/models/Message'; +import {getCachedUserPartialResponse} from '@app/api/user/UserCacheHelpers'; +import {ChannelTypes, MessageReferenceTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import { + ServerMessageFlags, + TEXT_THREAD_PARENT_CHANNEL_TYPES, + THREAD_MESSAGE_FLAG_MASK, +} from '@fluxer/constants/src/ThreadConstants'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; + +interface ThreadListQuery { + limit: number; + before?: MessageID; + after?: MessageID; + around?: MessageID; +} + +export function carriesThreadArtifact(response: MessageResponse): boolean { + if (response.type === MessageTypes.THREAD_CREATED || response.type === MessageTypes.THREAD_STARTER_MESSAGE) { + return true; + } + if ((response.flags & THREAD_MESSAGE_FLAG_MASK) !== 0 || response.thread !== undefined) return true; + if (response.message_snapshots?.some((snapshot) => (snapshot.flags & THREAD_MESSAGE_FLAG_MASK) !== 0)) return true; + const referenced = response.referenced_message; + return referenced != null && carriesThreadArtifact(referenced); +} + +function maskResponse(response: MessageResponse): MessageResponse { + const {thread: _thread, ...rest} = response; + const masked: MessageResponse = {...rest, flags: response.flags & ~THREAD_MESSAGE_FLAG_MASK}; + if (response.message_snapshots) { + masked.message_snapshots = response.message_snapshots.map((snapshot) => ({ + ...snapshot, + flags: snapshot.flags & ~THREAD_MESSAGE_FLAG_MASK, + })); + } + if (response.referenced_message) { + masked.referenced_message = + response.referenced_message.type === MessageTypes.THREAD_CREATED + ? null + : maskResponse(response.referenced_message); + } + return masked; +} + +export function maskThreadArtifacts(responses: Array): Array { + if (!responses.some(carriesThreadArtifact)) return responses; + return responses.filter((response) => response.type !== MessageTypes.THREAD_CREATED).map(maskResponse); +} + +export function maskThreadArtifactsFor( + viewer: ThreadViewer, + guildId: string | bigint | null, + responses: Array, +): Array { + if (!responses.some(carriesThreadArtifact)) return responses; + if (guildId !== null && viewerActive(viewer, guildId)) return responses; + return maskThreadArtifacts(responses); +} + +export function maskThreadArtifactsByChannel( + viewer: ThreadViewer, + channelById: Map, + responses: Array, +): Array { + if (!responses.some(carriesThreadArtifact)) return responses; + const byGuild = new Map>(); + for (const response of responses) { + const guildKey = channelById.get(response.channel_id)?.guildId?.toString() ?? ''; + const group = byGuild.get(guildKey) ?? []; + group.push(response); + byGuild.set(guildKey, group); + } + const masked = new Map(); + for (const [guildKey, group] of byGuild) { + const shaped = maskThreadArtifactsFor(viewer, guildKey === '' ? null : guildKey, group); + if (shaped === group) continue; + const byId = new Map(shaped.map((response) => [response.id, response])); + for (const response of group) masked.set(response, byId.get(response.id) ?? null); + } + if (masked.size === 0) return responses; + return responses.flatMap((response) => { + const shaped = masked.get(response); + if (shaped === undefined) return [response]; + return shaped === null ? [] : [shaped]; + }); +} + +export class ThreadMessageResponses { + constructor( + private readonly channelRepository: IChannelRepositoryAggregate, + private readonly responseDataService: MessageResponseDataService, + private readonly userCacheService: UserCacheService, + ) {} + + async shape(params: { + viewer: ThreadViewer; + userId: UserID; + authChannel: AuthenticatedChannel; + responses: Array; + requestCache: RequestCache; + query?: ThreadListQuery; + }): Promise> { + const {authChannel, viewer} = params; + const guildId = authChannel.channel.guildId; + let responses = params.responses; + if (guildId === null || !viewerActive(viewer, guildId)) { + return maskThreadArtifacts(responses); + } + if (authChannel.thread && params.query && (await this.starterBelongsToPage(authChannel, responses, params.query))) { + const starter = await this.buildStarter(params); + if (starter) { + responses = [...responses, starter]; + if (responses.length > params.query.limit) responses = responses.slice(responses.length - params.query.limit); + } + } + return this.attachThreads(responses, params.userId); + } + + async getStarter(params: { + viewer: ThreadViewer; + userId: UserID; + authChannel: AuthenticatedChannel; + messageId: MessageID; + requestCache: RequestCache; + }): Promise { + const {authChannel} = params; + if (!authChannel.thread || params.messageId.toString() !== authChannel.channel.id.toString()) return null; + return this.buildStarter(params); + } + + async getFirstMessages(params: { + userId: UserID; + guildId: GuildID; + canReadMessageHistory: boolean; + threadIds: Array; + }): Promise> { + if (params.threadIds.length === 0) return new Map(); + const messages = await Promise.all( + params.threadIds.map((threadId) => + this.channelRepository.messages.getMessage(threadId, channelIdToMessageId(threadId)), + ), + ); + const found = messages.filter((message): message is Message => message !== null); + const responses = await this.responseDataService.buildMessages({ + userId: params.userId, + messages: found, + access: { + sourceGuildId: params.guildId, + messageHistoryCutoff: null, + canReadMessageHistory: params.canReadMessageHistory, + }, + }); + return new Map(responses.map((response) => [response.channel_id, response])); + } + + private async starterBelongsToPage( + authChannel: AuthenticatedChannel, + responses: Array, + query: ThreadListQuery, + ): Promise { + const threadId = BigInt(authChannel.channel.id); + if (query.after !== undefined) return BigInt(query.after) < threadId; + if (query.around !== undefined && BigInt(query.around) === threadId) return true; + if (query.before !== undefined && BigInt(query.before) <= threadId) return false; + if (responses.length >= query.limit) return false; + const oldest = responses.reduce((min, response) => { + const id = BigInt(response.id); + return min === undefined || id < min ? id : min; + }, undefined); + const anchor = + oldest ?? + (query.before !== undefined ? BigInt(query.before) : undefined) ?? + (query.around !== undefined ? BigInt(query.around) + 1n : undefined); + const older = await this.channelRepository.messages.listMessages( + createChannelID(threadId), + anchor !== undefined ? createMessageID(anchor) : undefined, + 1, + ); + return older.length === 0; + } + + private async buildStarter(params: { + userId: UserID; + authChannel: AuthenticatedChannel; + requestCache: RequestCache; + }): Promise { + const thread = params.authChannel.thread; + const channel = params.authChannel.channel; + if (!thread?.state.hasStarter || !TEXT_THREAD_PARENT_CHANNEL_TYPES.has(thread.parent.type)) return null; + const source = await this.responseDataService.getMessage({ + userId: params.userId, + channelId: thread.parent.id, + messageId: createMessageID(BigInt(channel.id)), + access: messageResponseAccessForGuild(channel.guildId), + }); + const author = + source?.author ?? + (channel.ownerId + ? await getCachedUserPartialResponse({ + userId: channel.ownerId, + userCacheService: this.userCacheService, + requestCache: params.requestCache, + }) + : null); + if (!author) return null; + const [referenced] = source ? await this.attachThreads([source], params.userId) : [null]; + return { + id: channel.id.toString(), + channel_id: channel.id.toString(), + author, + webhook_id: null, + type: MessageTypes.THREAD_STARTER_MESSAGE, + flags: 0, + content: '', + timestamp: snowflakeToDate(channel.id).toISOString(), + edited_timestamp: null, + pinned: false, + mention_everyone: false, + tts: false, + mentions: [], + mention_roles: [], + embeds: [], + attachments: [], + message_reference: { + channel_id: thread.parent.id.toString(), + message_id: channel.id.toString(), + guild_id: channel.guildId?.toString() ?? null, + type: MessageReferenceTypes.DEFAULT, + }, + referenced_message: referenced ?? null, + }; + } + + private async attachThreads(responses: Array, userId: UserID): Promise> { + const threadIds = responses + .filter((response) => (response.flags & ServerMessageFlags.HAS_THREAD) !== 0) + .map((response) => createChannelID(BigInt(response.id))); + if (threadIds.length === 0) return responses; + const views = await this.loadThreadViews(threadIds, userId); + return responses.map((response) => { + const view = views.get(response.id); + return view ? {...response, thread: view} : response; + }); + } + + private async loadThreadViews( + threadIds: Array, + userId: UserID, + ): Promise>> { + const {channelData, threads} = this.channelRepository; + const [channels, states, stats, members] = await Promise.all([ + channelData.listChannels(threadIds), + threads.getStates(threadIds), + threads.getStatsMany(threadIds), + Promise.all(threadIds.map((threadId) => threads.getMember(threadId, userId))), + ]); + const channelById = new Map(channels.map((channel) => [channel.id.toString(), channel])); + const memberById = new Map(members.flatMap((member) => (member ? [[member.threadId.toString(), member]] : []))); + const views = new Map>(); + for (const state of states) { + const key = state.threadId.toString(); + const channel = channelById.get(key); + const threadStats = stats.get(state.threadId); + if (!channel || !threadStats) continue; + views.set( + key, + mapThreadToResponse( + {channel, state, stats: threadStats, parentType: ChannelTypes.GUILD_TEXT}, + memberById.get(key) ?? null, + ), + ); + } + return views; + } +} diff --git a/fluxer_api/src/api/channel/services/message/UserMessageDeletionService.ts b/fluxer_api/src/api/channel/services/message/UserMessageDeletionService.ts index d15380b50..0b9fda8df 100644 --- a/fluxer_api/src/api/channel/services/message/UserMessageDeletionService.ts +++ b/fluxer_api/src/api/channel/services/message/UserMessageDeletionService.ts @@ -7,7 +7,7 @@ import { type CrosspostWorkerService, enqueueCrosspostSourceRemoval, } from '@app/api/channel/services/message/CrosspostPropagation'; -import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers'; +import {decrementThreadMessageCount, purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers'; import { isChannelEligible, isTimestampInWindow, @@ -169,10 +169,16 @@ export class UserMessageDeletionService { } } if (scope.guildId) { - const channels = await this.deps.channelRepository.channelData.listGuildChannels(scope.guildId); + const channels = await this.deps.channelRepository.channelData.listGuildChannels(scope.guildId, 'complete'); for (const channel of channels) { allowlist.add(channel.id.toString()); } + const threadIds = await this.deps.channelRepository.threads.listGuildThreadIds(scope.guildId, { + parents: channels, + }); + for (const threadId of threadIds) { + allowlist.add(threadId.toString()); + } } return allowlist; } @@ -236,6 +242,7 @@ export class UserMessageDeletionService { ), ); await this.deps.channelRepository.bulkDeleteMessages(channelId, messageIds); + await decrementThreadMessageCount(this.deps.channelRepository, channel, messageIds); await this.eventDispatcher.dispatchBulkDelete(channel, messageIds); await enqueueCrosspostSourceRemoval(this.deps.workerService, { messages: messageObjects, diff --git a/fluxer_api/src/api/channel/services/tests/ChannelAuthThreadGate.test.ts b/fluxer_api/src/api/channel/services/tests/ChannelAuthThreadGate.test.ts new file mode 100644 index 000000000..04f81b097 --- /dev/null +++ b/fluxer_api/src/api/channel/services/tests/ChannelAuthThreadGate.test.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createChannelID, createGuildID, createUserID} from '@app/api/BrandedTypes'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import {ChannelAuthService} from '@app/api/channel/services/channel_data/ChannelAuthService'; +import { + SYSTEM_THREAD_VIEWER, + syncChannelThreadsConfig, + type ThreadViewer, +} from '@app/api/experiment/ChannelThreadsGate'; +import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import type {Channel} from '@app/api/models/Channel'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; +import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {afterEach, describe, expect, it} from 'vitest'; + +const GUILD_ID = createGuildID(100n); +const CHANNEL_ID = createChannelID(300n); +const USER_ID = createUserID(10n); +const REACHED_GATEWAY = new Error('reached the gateway permission lookup'); + +function createService(type: number): ChannelAuthService { + const channel = {id: CHANNEL_ID, type, guildId: GUILD_ID, parentId: createChannelID(200n)} as unknown as Channel; + const channelRepository = { + channelData: {findUnique: async () => channel}, + threads: {getState: async () => null, getMember: async () => null}, + } as unknown as IChannelRepositoryAggregate; + const gatewayService = { + getGuildAuthContext: async () => { + throw REACHED_GATEWAY; + }, + getGuildMember: async () => { + throw REACHED_GATEWAY; + }, + } as unknown as IGatewayService; + return new ChannelAuthService( + channelRepository, + {} as IUserRepository, + {} as IGuildRepositoryAggregate, + gatewayService, + ); +} + +function enroll(): void { + const raw = JSON.stringify( + ChannelThreadsConfigSchema.parse({enabled: true, enabled_guild_ids: ['100'], included_user_ids: ['10']}), + ); + syncChannelThreadsConfig(raw, (value) => ChannelThreadsConfigSchema.parse(JSON.parse(value ?? '{}'))); +} + +const capableViewer: ThreadViewer = {kind: 'user', userId: USER_ID, bot: false, capable: true}; + +describe('getChannelAuthenticated thread gate', () => { + afterEach(() => { + syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({})); + }); + + it('never consults the gate for control channel types', async () => { + await expect( + createService(ChannelTypes.GUILD_TEXT).getChannelAuthenticated({ + userId: USER_ID, + channelId: CHANNEL_ID, + viewer: {...capableViewer, capable: false}, + }), + ).rejects.toBe(REACHED_GATEWAY); + }); + + it.each([ + ChannelTypes.PUBLIC_THREAD, + ChannelTypes.PRIVATE_THREAD, + ChannelTypes.GUILD_FORUM, + ChannelTypes.GUILD_MEDIA, + ])('hides channel type %i as unknown outside the experiment', async (type) => { + await expect( + createService(type).getChannelAuthenticated({userId: USER_ID, channelId: CHANNEL_ID, viewer: capableViewer}), + ).rejects.toBeInstanceOf(UnknownChannelError); + }); + + it('hides a thread from an enrolled user whose client lacks the capability', async () => { + enroll(); + await expect( + createService(ChannelTypes.PUBLIC_THREAD).getChannelAuthenticated({ + userId: USER_ID, + channelId: CHANNEL_ID, + viewer: {...capableViewer, capable: false}, + }), + ).rejects.toBeInstanceOf(UnknownChannelError); + }); + + it('lets an active viewer and the system through to the permission checks', async () => { + enroll(); + const service = createService(ChannelTypes.PUBLIC_THREAD); + await expect( + service.getChannelAuthenticated({userId: USER_ID, channelId: CHANNEL_ID, viewer: capableViewer}), + ).rejects.toBe(REACHED_GATEWAY); + await expect( + service.getChannelAuthenticated({userId: USER_ID, channelId: CHANNEL_ID, viewer: SYSTEM_THREAD_VIEWER}), + ).rejects.toBe(REACHED_GATEWAY); + }); +}); diff --git a/fluxer_api/src/api/channel/services/thread/ForumTagRules.ts b/fluxer_api/src/api/channel/services/thread/ForumTagRules.ts new file mode 100644 index 000000000..9eea7428c --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ForumTagRules.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ThreadParentConfig} from '@app/api/models/ThreadParentConfig'; +import {ChannelFlags} from '@fluxer/constants/src/ThreadConstants'; +import {ForumTagRequiredError} from '@fluxer/errors/src/domains/channel/ForumTagRequiredError'; +import {NoTagsAvailableToNonModeratorsError} from '@fluxer/errors/src/domains/channel/NoTagsAvailableToNonModeratorsError'; +import {UnknownForumTagError} from '@fluxer/errors/src/domains/channel/UnknownForumTagError'; +import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError'; + +export function resolveAppliedTags(params: { + config: ThreadParentConfig | null; + requested: ReadonlyArray; + previous: ReadonlyArray; + moderator: boolean; +}): Array { + const available = new Map((params.config?.availableTags ?? []).map((tag) => [tag.id, tag])); + const stale = new Set(params.previous.filter((id) => !available.has(id))); + const requested = [...new Set(params.requested)].filter((id) => !stale.has(id)); + for (const id of requested) { + if (!available.has(id)) throw new UnknownForumTagError(); + } + const previous = new Set(params.previous.filter((id) => available.has(id))); + if (!params.moderator) { + const changed = [ + ...requested.filter((id) => !previous.has(id)), + ...[...previous].filter((id) => !requested.includes(id)), + ]; + if (changed.some((id) => available.get(id)?.moderated)) throw new MissingPermissionsError(); + } + const requireTag = ((params.config?.flags ?? 0) & ChannelFlags.REQUIRE_TAG) !== 0; + if (requireTag && requested.length === 0) { + if (!params.moderator && ![...available.values()].some((tag) => !tag.moderated)) { + throw new NoTagsAvailableToNonModeratorsError(); + } + throw new ForumTagRequiredError(); + } + return requested; +} diff --git a/fluxer_api/src/api/channel/services/thread/ForumTagService.ts b/fluxer_api/src/api/channel/services/thread/ForumTagService.ts new file mode 100644 index 000000000..7f09eb234 --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ForumTagService.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ForumTag} from '@app/api/models/ForumTag'; +import type {ThreadParentConfig} from '@app/api/models/ThreadParentConfig'; +import {UnknownForumTagError} from '@fluxer/errors/src/domains/channel/UnknownForumTagError'; +import type {ForumTagRequest, ForumTagUpdateRequest} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas'; + +export type ForumTagEdit = + | {kind: 'create'; tag: ForumTagRequest} + | {kind: 'update'; tagId: bigint; tag: ForumTagRequest} + | {kind: 'delete'; tagId: bigint}; + +function toUpdateRequest(tag: ForumTag): ForumTagUpdateRequest { + return { + id: tag.id, + name: tag.name, + moderated: tag.moderated, + emoji_id: tag.emojiId, + emoji_name: tag.emojiName, + }; +} + +export function applyForumTagEdit(config: ThreadParentConfig | null, edit: ForumTagEdit): Array { + const tags = (config?.availableTags ?? []).map(toUpdateRequest); + if (edit.kind === 'create') return [...tags, edit.tag]; + const index = tags.findIndex((tag) => tag.id === edit.tagId); + if (index === -1) throw new UnknownForumTagError(); + if (edit.kind === 'delete') return tags.filter((_, position) => position !== index); + return tags.map((tag, position) => (position === index ? {...edit.tag, id: edit.tagId} : tag)); +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadCreationService.ts b/fluxer_api/src/api/channel/services/thread/ThreadCreationService.ts new file mode 100644 index 000000000..3b8a25ef2 --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadCreationService.ts @@ -0,0 +1,563 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type ChannelID, + channelIdToMessageId, + createChannelID, + createGuildID, + createMessageID, + type MessageID, + type UserID, +} from '@app/api/BrandedTypes'; +import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; +import type {MessageRequest} from '@app/api/channel/MessageTypes'; +import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; +import {withChannelFollowLock} from '@app/api/channel/services/ChannelFollowers'; +import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch'; +import {buildBroadcastMessageData} from '@app/api/channel/services/message/MessageGatewayDispatch'; +import {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock'; +import {resolveAppliedTags} from '@app/api/channel/services/thread/ForumTagRules'; +import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials'; +import { + dispatchThreadEvents, + type ThreadDispatchEvent, + threadCreateEvent, + threadDeleteEvent, +} from '@app/api/channel/services/thread/ThreadDispatch'; +import { + mapThreadToResponse, + serializeThreadForAudit, + type ThreadView, +} from '@app/api/channel/services/thread/ThreadMappers'; +import {withThreadParentFields} from '@app/api/channel/services/thread/ThreadParentSettings'; +import type {ThreadServiceContext} from '@app/api/channel/services/thread/ThreadServiceContext'; +import {loadThreadView} from '@app/api/channel/services/thread/ThreadViews'; +import {enqueueThreadSearchSync} from '@app/api/channel/threads/ThreadJobs'; +import {threadsCreatedTotal} from '@app/api/channel/threads/ThreadMetrics'; +import type {ChannelRow} from '@app/api/database/types/ChannelTypes'; +import type {MessageRow} from '@app/api/database/types/MessageTypes'; +import {ensureActiveGuildTainted, type ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; +import {contentModerationService} from '@app/api/infrastructure/ContentModerationService'; +import {Logger} from '@app/api/Logger'; +import {createRequestCache, type RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import type {Channel} from '@app/api/models/Channel'; +import type {Message} from '@app/api/models/Message'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import type {User} from '@app/api/models/User'; +import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService'; +import {assertAccountNotLimited} from '@app/api/user/AccountLimit'; +import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; +import {ChannelTypes, MessageReferenceTypes, MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import { + DEFAULT_THREAD_AUTO_ARCHIVE_DURATION, + MAX_ACTIVE_THREADS_PER_GUILD, + publicThreadTypeFor, + resolveTextThreadType, + ServerMessageFlags, + TEXT_THREAD_PARENT_CHANNEL_TYPES, + ThreadMemberFlags, +} from '@fluxer/constants/src/ThreadConstants'; +import { + canCreateThread, + isThreadModerator, + type ThreadCreateKind, + withImplicitThreadBits, +} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {CannotEditSystemMessageError} from '@fluxer/errors/src/domains/channel/CannotEditSystemMessageError'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; +import {MaxActiveThreadsError} from '@fluxer/errors/src/domains/channel/MaxActiveThreadsError'; +import {ThreadAlreadyCreatedForMessageError} from '@fluxer/errors/src/domains/channel/ThreadAlreadyCreatedForMessageError'; +import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError'; +import {SlowmodeRateLimitError} from '@fluxer/errors/src/domains/core/SlowmodeRateLimitError'; +import type { + StartForumThreadRequest, + StartForumThreadResponse, +} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; +import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets'; + +const THREAD_CREATED_MESSAGE_MIN_SOURCE_AGE_MS = 5 * 60 * 1000; + +function slowmodeThreadKey(parentId: ChannelID, userId: UserID): string { + return `slowmode-thread:${parentId}:${userId}`; +} + +export type ForumPostInput = Omit & {message: MessageRequest}; + +interface CreateThreadInput { + name: string; + autoArchiveDuration?: number; + rateLimitPerUser?: number; +} + +type ThreadCreator = {kind: 'user'; user: User; parentAuth: AuthenticatedChannel} | {kind: 'webhook'; ownerId: UserID}; + +interface NewThread { + parent: Channel; + creator: ThreadCreator; + threadId: ChannelID; + type: number; + input: CreateThreadInput; + invitable: boolean | null; + hasStarter: boolean; + appliedTags?: Array; +} + +export class ThreadCreationService { + constructor(private readonly ctx: ThreadServiceContext) {} + + async createFromMessage(params: { + viewer: ThreadViewer; + user: User; + channelId: ChannelID; + messageId: MessageID; + input: CreateThreadInput; + requestCache: RequestCache; + auditLogReason: string | null; + }): Promise { + const parentAuth = await this.authorizeParent(params.viewer, params.user, params.channelId, 'from_message'); + const source = await this.ctx.channelRepository.messages.getMessage(parentAuth.channel.id, params.messageId); + if (!source) throw new UnknownMessageError(); + if (source.type !== MessageTypes.DEFAULT && source.type !== MessageTypes.REPLY) { + throw new CannotEditSystemMessageError(); + } + if ((source.flags & ServerMessageFlags.HAS_THREAD) !== 0) throw new ThreadAlreadyCreatedForMessageError(); + const {view, members} = await this.create({ + parent: parentAuth.channel, + creator: {kind: 'user', user: params.user, parentAuth}, + threadId: createChannelID(BigInt(source.id)), + type: publicThreadTypeFor(parentAuth.channel.type), + input: params.input, + invitable: null, + hasStarter: true, + }); + await this.recordCreateAudit(view, params.user.id, params.auditLogReason); + const events: Array = [threadCreateEvent(view, {members, newlyCreated: true})]; + const updatedSource = await new MessageWriteLock( + this.ctx.cacheService, + this.ctx.channelRepository.messages, + ).withFreshMessage(parentAuth.channel.id, source.id, async (current) => + current + ? this.ctx.channelRepository.messages.upsertMessage( + {...current.toRow(), flags: current.flags | ServerMessageFlags.HAS_THREAD}, + current.toRow(), + ) + : null, + ); + if (updatedSource) { + events.push({ + event: 'MESSAGE_UPDATE', + data: { + ...(await buildBroadcastMessageData({channel: parentAuth.channel, message: updatedSource})), + thread: mapThreadToResponse(view), + __thread_only_update: true, + }, + }); + } + const starter = await this.buildStarterEvent(params.viewer, params.user.id, view, params.requestCache); + if (starter) events.push(starter); + if (Date.now() - snowflakeToDate(source.id).getTime() >= THREAD_CREATED_MESSAGE_MIN_SOURCE_AGE_MS) { + events.push(await this.createThreadCreatedMessage(parentAuth.channel, view, params.user.id)); + } + await dispatchThreadEvents(this.ctx.gatewayService, view.state.guildId, events); + return mapThreadToResponse(view); + } + + async authenticateParent(viewer: ThreadViewer, userId: UserID, channelId: ChannelID): Promise { + return this.ctx.channelAuth.getChannelAuthenticated({userId, channelId, viewer}); + } + + async createTextThread(params: { + user: User; + parentAuth: AuthenticatedChannel; + type: number; + invitable?: boolean; + input: CreateThreadInput; + auditLogReason: string | null; + }): Promise { + const type = resolveTextThreadType(params.parentAuth.channel.type, params.type); + if (type === null) throw new InvalidChannelTypeError(); + const isPrivate = type === ChannelTypes.PRIVATE_THREAD; + const parentAuth = await this.authorizeAuthenticatedParent( + params.parentAuth, + params.user, + isPrivate ? 'private' : 'public', + ); + const threadId = createChannelID(await this.ctx.snowflakeService.generate()); + const {view, members} = await this.create({ + parent: parentAuth.channel, + creator: {kind: 'user', user: params.user, parentAuth}, + threadId, + type, + input: params.input, + invitable: isPrivate ? (params.invitable ?? true) : null, + hasStarter: false, + }); + await this.recordCreateAudit(view, params.user.id, params.auditLogReason); + const events: Array = [threadCreateEvent(view, {members, newlyCreated: true})]; + if (!isPrivate) events.push(await this.createThreadCreatedMessage(parentAuth.channel, view, params.user.id)); + await dispatchThreadEvents(this.ctx.gatewayService, view.state.guildId, events); + return mapThreadToResponse(view); + } + + async createForumPost(params: { + viewer: ThreadViewer; + user: User; + parentAuth: AuthenticatedChannel; + body: ForumPostInput; + requestCache: RequestCache; + auditLogReason: string | null; + }): Promise { + const {parentAuth, user, body} = params; + assertAccountNotLimited(user); + const {channel: parent, guild} = parentAuth; + if (!guild || !parent.isThreadOnly()) throw new InvalidChannelTypeError(); + if (body.type !== undefined && body.type !== ChannelTypes.PUBLIC_THREAD) throw new InvalidChannelTypeError(); + const actor = await this.ctx.parentActor(parentAuth, user.id); + assertThreadAllowed(canCreateThread(actor, 'forum_post')); + const config = await this.ctx.channelRepository.threads.getParentConfig(parent.guildId!, parent.id); + const appliedTags = resolveAppliedTags({ + config, + requested: (body.applied_tags ?? []).map((id) => BigInt(id)), + previous: [], + moderator: isThreadModerator(withImplicitThreadBits(actor.permissions), actor), + }); + await this.ctx.validateForumStarter({user, parentAuth, data: body.message}); + const threadId = createChannelID(await this.ctx.snowflakeService.generate()); + const {view, members} = await this.create({ + parent, + creator: {kind: 'user', user, parentAuth}, + threadId, + type: ChannelTypes.PUBLIC_THREAD, + input: { + name: body.name, + autoArchiveDuration: body.auto_archive_duration, + rateLimitPerUser: body.rate_limit_per_user, + }, + invitable: null, + hasStarter: true, + appliedTags, + }); + await dispatchThreadEvents(this.ctx.gatewayService, view.state.guildId, [ + threadCreateEvent(view, {members, newlyCreated: true}), + ]); + let message: MessageResponse; + try { + message = await this.ctx.sendForumStarter({ + user, + viewer: params.viewer, + parentAuth, + threadId, + data: body.message, + requestCache: params.requestCache, + }); + } catch (error) { + await this.discardThread(view, members, parent); + await this.ctx.rateLimitService.resetLimit(slowmodeThreadKey(parent.id, user.id)); + throw error; + } + await this.recordCreateAudit(view, user.id, params.auditLogReason); + await this.ackForumPost(params.viewer, user, parent, threadId); + const created = await this.ctx.channelRepository.channelData.findUnique(threadId); + return {...mapThreadToResponse(created ? {...view, channel: created} : view), message}; + } + + async createWebhookForumPost(params: { + webhookId: UserID; + parent: Channel; + name: string; + appliedTags: Array; + sendStarter: (thread: Channel) => Promise; + }): Promise { + const {parent} = params; + if (!parent.isThreadOnly() || parent.guildId === null) throw new InvalidChannelTypeError(); + const config = await this.ctx.channelRepository.threads.getParentConfig(parent.guildId, parent.id); + const appliedTags = resolveAppliedTags({config, requested: params.appliedTags, previous: [], moderator: false}); + const threadId = createChannelID(await this.ctx.snowflakeService.generate()); + const {view, members} = await this.create({ + parent, + creator: {kind: 'webhook', ownerId: params.webhookId}, + threadId, + type: ChannelTypes.PUBLIC_THREAD, + input: {name: params.name}, + invitable: null, + hasStarter: true, + appliedTags, + }); + await dispatchThreadEvents(this.ctx.gatewayService, view.state.guildId, [ + threadCreateEvent(view, {members, newlyCreated: true}), + ]); + try { + return await params.sendStarter(view.channel); + } catch (error) { + await this.discardThread(view, members, parent); + throw error; + } + } + + private async discardThread(view: ThreadView, members: Array, parent: Channel): Promise { + await this.ctx.channelRepository.threads.purgeThread(view.channel.id); + await this.ctx.channelRepository.threads.revertParentLastMessageId( + parent.id, + view.channel.id, + parent.lastMessageId, + ); + await this.ctx.archiveQueue.remove(view.state.guildId, view.channel.id); + await deleteThreadSearchDocuments([view.channel.id]); + const reverted = await this.ctx.channelRepository.channelData.findUnique(parent.id); + if (reverted) { + await dispatchChannelEvent({ + gatewayService: this.ctx.gatewayService, + channel: reverted, + event: 'CHANNEL_UPDATE', + data: await withThreadParentFields( + this.ctx.channelRepository.threads, + reverted, + await mapChannelToResponse({ + channel: reverted, + currentUserId: null, + userCacheService: this.ctx.userCacheService, + requestCache: createRequestCache(), + }), + ), + }); + } + await dispatchThreadEvents(this.ctx.gatewayService, view.state.guildId, [ + threadDeleteEvent( + view.state, + members.map((member) => member.userId), + ), + ]); + } + + private async ackForumPost(viewer: ThreadViewer, user: User, parent: Channel, threadId: ChannelID): Promise { + if (user.isBot || viewer.kind !== 'user') return; + try { + await this.ctx.readStateService.ackMessage({ + userId: user.id, + channelId: parent.id, + messageId: channelIdToMessageId(threadId), + mentionCount: 0, + capable: viewer.capable, + channel: {type: parent.type, guildId: parent.guildId}, + }); + } catch (error) { + Logger.warn({error, channelId: parent.id.toString()}, 'Failed to ack forum after creating a post'); + } + } + + private async authorizeParent( + viewer: ThreadViewer, + user: User, + channelId: ChannelID, + kind: ThreadCreateKind, + ): Promise { + const parentAuth = await this.ctx.channelAuth.getChannelAuthenticated({userId: user.id, channelId, viewer}); + return this.authorizeAuthenticatedParent(parentAuth, user, kind); + } + + private async authorizeAuthenticatedParent( + parentAuth: AuthenticatedChannel, + user: User, + kind: ThreadCreateKind, + ): Promise { + assertAccountNotLimited(user); + const {channel, guild} = parentAuth; + if (!guild || !TEXT_THREAD_PARENT_CHANNEL_TYPES.has(channel.type)) throw new InvalidChannelTypeError(); + assertThreadAllowed(canCreateThread(await this.ctx.parentActor(parentAuth, user.id), kind)); + return parentAuth; + } + + private async enforceCreateSlowmode(parentAuth: AuthenticatedChannel, user: User): Promise { + const rateLimitPerUser = parentAuth.channel.rateLimitPerUser ?? 0; + if (rateLimitPerUser <= 0 || user.isBot) return; + if (await parentAuth.hasPermission(Permissions.BYPASS_SLOWMODE)) return; + const result = await this.ctx.rateLimitService.checkLimit({ + identifier: slowmodeThreadKey(parentAuth.channel.id, user.id), + maxAttempts: 1, + windowMs: rateLimitPerUser * 1000, + algorithm: 'leaky_bucket', + }); + if (!result.allowed) { + throw new SlowmodeRateLimitError({retryAfter: result.retryAfter, retryAfterDecimal: result.retryAfterDecimal}); + } + } + + private async create(params: NewThread): Promise<{view: ThreadView; members: Array}> { + const {parent, creator} = params; + const guildId = parent.guildId!; + const ownerId = creator.kind === 'user' ? creator.user.id : creator.ownerId; + const {threads} = this.ctx.channelRepository; + if ((await threads.countActiveThreads(guildId)) >= MAX_ACTIVE_THREADS_PER_GUILD) { + throw new MaxActiveThreadsError(MAX_ACTIVE_THREADS_PER_GUILD); + } + if (creator.kind === 'user') await this.enforceCreateSlowmode(creator.parentAuth, creator.user); + const state = await this.storeThread(params).catch(async (error: unknown) => { + if (creator.kind === 'user') { + await this.ctx.rateLimitService.resetLimit(slowmodeThreadKey(parent.id, creator.user.id)); + } + throw error; + }); + const channel = await this.ctx.channelRepository.channelData.findUnique(params.threadId); + if (!channel) throw new InvalidChannelTypeError(); + const view = await loadThreadView(this.ctx.channelRepository, channel, state, parent); + const members = creator.kind === 'user' ? await threads.getMembers(params.threadId, [ownerId]) : []; + const kind = parent.isThreadOnly() ? 'forum_post' : params.hasStarter ? 'from_message' : 'standalone'; + threadsCreatedTotal.inc(`kind="${kind}",type="${params.type}"`); + await this.ctx.archiveQueue.schedule(state, null); + enqueueThreadSearchSync(params.threadId); + return {view, members}; + } + + private async storeThread(params: NewThread): Promise { + const {parent, creator} = params; + const guildId = parent.guildId!; + const ownerId = creator.kind === 'user' ? creator.user.id : creator.ownerId; + const {threads} = this.ctx.channelRepository; + contentModerationService.scanText(params.input.name, { + userId: ownerId, + guildId, + channelId: params.threadId, + messageId: null, + surface: 'profile_field', + }); + const parentConfig = await threads.getParentConfig(guildId, parent.id); + await ensureActiveGuildTainted(guildId); + const now = new Date(); + const row: ChannelRow = { + channel_id: params.threadId, + guild_id: guildId, + type: params.type, + name: params.input.name, + topic: null, + icon_hash: null, + url: null, + parent_id: parent.id, + position: null, + owner_id: ownerId, + recipient_ids: null, + nsfw: null, + rate_limit_per_user: params.input.rateLimitPerUser ?? parentConfig?.defaultThreadRateLimitPerUser ?? 0, + bitrate: null, + user_limit: null, + voice_connection_limit: null, + rtc_region: null, + last_message_id: null, + last_pin_timestamp: null, + permission_overwrites: null, + nicks: null, + soft_deleted: false, + indexed_at: now, + version: 1, + }; + const write = () => + threads.create({ + channel: row, + parentType: parent.type, + autoArchiveDuration: params.input.autoArchiveDuration ?? DEFAULT_THREAD_AUTO_ARCHIVE_DURATION, + invitable: params.invitable, + flags: 0, + appliedTags: params.appliedTags ?? [], + hasStarter: params.hasStarter, + createdAt: now, + members: + creator.kind === 'user' + ? [{userId: creator.user.id, flags: creator.user.isBot ? 0 : ThreadMemberFlags.HAS_INTERACTED}] + : [], + }); + if (!TEXT_THREAD_PARENT_CHANNEL_TYPES.has(parent.type)) return write(); + return withChannelFollowLock(this.ctx.cacheService, parent.id, async () => { + const current = await this.ctx.channelRepository.channelData.findUnique(parent.id); + if (current?.type !== parent.type) throw new InvalidChannelTypeError(); + return write(); + }); + } + + private async recordCreateAudit(view: ThreadView, userId: UserID, auditLogReason: string | null): Promise { + await this.ctx.recordAudit({ + guildId: view.state.guildId, + userId, + action: AuditLogActionType.THREAD_CREATE, + targetId: view.channel.id, + auditLogReason, + changes: this.ctx.guildAuditLogService.computeChanges(null, serializeThreadForAudit(view)), + }); + } + + private async buildStarterEvent( + viewer: ThreadViewer, + userId: UserID, + view: ThreadView, + requestCache: RequestCache, + ): Promise { + const threadAuth = await this.ctx.channelAuth.getChannelAuthenticated({ + userId, + channelId: view.channel.id, + viewer, + skipNsfwValidation: true, + }); + const starter = await this.ctx.threadMessageResponses.getStarter({ + viewer, + userId, + authChannel: threadAuth, + messageId: createMessageID(BigInt(view.channel.id)), + requestCache, + }); + if (!starter) return null; + const referenced = starter.referenced_message; + if (referenced?.thread) { + const {member: _member, ...thread} = referenced.thread; + starter.referenced_message = {...referenced, thread}; + } + return {event: 'MESSAGE_CREATE', data: {...starter, channel_type: view.channel.type}}; + } + + private async createThreadCreatedMessage( + parent: Channel, + view: ThreadView, + userId: UserID, + ): Promise { + const messageId = createMessageID(await this.ctx.snowflakeService.generateForChannel(parent.id)); + const row: MessageRow = { + channel_id: parent.id, + bucket: BucketUtils.makeBucket(messageId), + message_id: messageId, + author_id: userId, + type: MessageTypes.THREAD_CREATED, + webhook_id: null, + webhook_name: null, + webhook_avatar_hash: null, + content: view.channel.name, + edited_timestamp: null, + pinned_timestamp: null, + flags: 0, + mention_everyone: false, + mention_users: null, + mention_roles: null, + mention_channels: null, + attachments: null, + embeds: null, + sticker_items: null, + message_reference: { + channel_id: view.channel.id, + message_id: null as unknown as MessageID, + guild_id: createGuildID(BigInt(view.state.guildId)), + type: MessageReferenceTypes.DEFAULT, + }, + message_snapshots: null, + call: null, + has_reaction: false, + version: 1, + }; + const message: Message = await this.ctx.channelRepository.messages.upsertMessage(row, null, { + skipParentLastMessageId: true, + }); + return { + event: 'MESSAGE_CREATE', + data: {...(await buildBroadcastMessageData({channel: parent, message})), channel_type: parent.type}, + }; + } +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadDeletionService.ts b/fluxer_api/src/api/channel/services/thread/ThreadDeletionService.ts new file mode 100644 index 000000000..ace9e233a --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadDeletionService.ts @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createMessageID, type UserID} from '@app/api/BrandedTypes'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import {buildBroadcastMessageData} from '@app/api/channel/services/message/MessageGatewayDispatch'; +import {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock'; +import { + dispatchThreadEvents, + type ThreadDispatchEvent, + threadDeleteEvent, +} from '@app/api/channel/services/thread/ThreadDispatch'; +import {serializeThreadForAudit} from '@app/api/channel/services/thread/ThreadMappers'; +import type {ThreadServiceContext} from '@app/api/channel/services/thread/ThreadServiceContext'; +import {loadThreadViews} from '@app/api/channel/services/thread/ThreadViews'; +import type {Channel} from '@app/api/models/Channel'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup'; +import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService'; +import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; +import { + MAX_THREAD_MEMBERS, + ServerMessageFlags, + TEXT_THREAD_PARENT_CHANNEL_TYPES, +} from '@fluxer/constants/src/ThreadConstants'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; +import type {ICacheService} from '@pkgs/cache/src/ICacheService'; + +export async function clearThreadSourceFlag( + channelRepository: IChannelRepositoryAggregate, + cacheService: ICacheService, + state: ThreadState, + parent?: Channel | null, +): Promise> { + if (!state.hasStarter) return []; + const parentChannel = parent ?? (await channelRepository.channelData.findUnique(state.parentId)); + if (!parentChannel || !TEXT_THREAD_PARENT_CHANNEL_TYPES.has(parentChannel.type)) return []; + const updated = await new MessageWriteLock(cacheService, channelRepository.messages).withFreshMessage( + parentChannel.id, + createMessageID(BigInt(state.threadId)), + async (source) => + source && (source.flags & ServerMessageFlags.HAS_THREAD) !== 0 + ? channelRepository.messages.upsertMessage( + {...source.toRow(), flags: source.flags & ~ServerMessageFlags.HAS_THREAD}, + source.toRow(), + ) + : null, + ); + if (!updated) return []; + return [ + { + event: 'MESSAGE_UPDATE', + data: { + ...(await buildBroadcastMessageData({channel: parentChannel, message: updated})), + __thread_only_update: true, + }, + }, + ]; +} + +export class ThreadDeletionService { + constructor(private readonly ctx: ThreadServiceContext) {} + + async deleteThread(params: { + thread: Channel; + actorId: UserID; + auditLogReason: string | null; + recordGuildAudit: boolean; + }): Promise { + const {thread} = params; + if (!thread.isThread() || thread.guildId === null) throw new InvalidChannelTypeError(); + const guildId = thread.guildId; + const state = await this.ctx.channelRepository.threads.getState(thread.id); + const [view] = state ? await loadThreadViews(this.ctx.channelRepository, [state]) : []; + const members = state + ? await this.ctx.channelRepository.threads.listMembers(thread.id, {limit: MAX_THREAD_MEMBERS}) + : []; + await deleteChannelMessageSearchDocuments(thread.id, {context: {source: 'thread_delete'}}); + await this.ctx.purgeChannelAttachments(thread); + await this.ctx.channelRepository.threads.purgeThread(thread.id); + await this.ctx.archiveQueue.remove(guildId, thread.id); + await deleteThreadSearchDocuments([thread.id]); + if (state) { + await dispatchThreadEvents(this.ctx.gatewayService, guildId, [ + threadDeleteEvent( + state, + members.map((member) => member.userId), + ), + ...(await clearThreadSourceFlag(this.ctx.channelRepository, this.ctx.cacheService, state)), + ]); + } + if (params.recordGuildAudit) { + await this.ctx.recordAudit({ + guildId, + userId: params.actorId, + action: AuditLogActionType.THREAD_DELETE, + targetId: thread.id, + auditLogReason: params.auditLogReason, + changes: view ? this.ctx.guildAuditLogService.computeChanges(serializeThreadForAudit(view), null) : null, + }); + } + } +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadDenials.ts b/fluxer_api/src/api/channel/services/thread/ThreadDenials.ts new file mode 100644 index 000000000..56e9be5be --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadDenials.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import type {ThreadDenial} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {ThreadArchivedError} from '@fluxer/errors/src/domains/channel/ThreadArchivedError'; +import {ThreadLockedError} from '@fluxer/errors/src/domains/channel/ThreadLockedError'; +import {UnknownThreadMemberError} from '@fluxer/errors/src/domains/channel/UnknownThreadMemberError'; +import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError'; +import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError'; +import {CommunicationDisabledError} from '@fluxer/errors/src/domains/moderation/CommunicationDisabledError'; + +export function threadDenialError(denial: ThreadDenial): Error { + switch (denial) { + case APIErrorCodes.MISSING_ACCESS: + return new MissingAccessError(); + case APIErrorCodes.MISSING_PERMISSIONS: + return new MissingPermissionsError(); + case APIErrorCodes.COMMUNICATION_DISABLED: + return new CommunicationDisabledError(); + case APIErrorCodes.THREAD_ARCHIVED: + return new ThreadArchivedError(); + case APIErrorCodes.THREAD_LOCKED: + return new ThreadLockedError(); + case APIErrorCodes.UNKNOWN_THREAD_MEMBER: + return new UnknownThreadMemberError(); + } +} + +export function assertThreadAllowed(denial: ThreadDenial | null): void { + if (denial !== null) throw threadDenialError(denial); +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadDispatch.ts b/fluxer_api/src/api/channel/services/thread/ThreadDispatch.ts new file mode 100644 index 000000000..b54b04b01 --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadDispatch.ts @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {GuildID, UserID} from '@app/api/BrandedTypes'; +import { + mapThreadMemberToRpcResponse, + mapThreadToResponse, + type ThreadView, +} from '@app/api/channel/services/thread/ThreadMappers'; +import type {GatewayDispatchEvent} from '@app/api/constants/Gateway'; +import {guildActive} from '@app/api/experiment/ChannelThreadsGate'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import {THREAD_MEMBER_COUNT_DISPLAY_CAP} from '@fluxer/constants/src/ThreadConstants'; + +export interface ThreadDispatchEvent { + event: GatewayDispatchEvent; + data: unknown; +} + +function internalThreadFields(view: ThreadView) { + return { + _fluxer_member_ids_preview: view.state.memberIdsPreview.map((id) => id.toString()), + }; +} + +export function threadCreateEvent( + view: ThreadView, + options: {members: Array; newlyCreated: boolean}, +): ThreadDispatchEvent { + return { + event: 'THREAD_CREATE', + data: { + ...mapThreadToResponse(view), + ...(options.newlyCreated ? {newly_created: true} : {}), + ...internalThreadFields(view), + _fluxer_members: options.members.map(mapThreadMemberToRpcResponse), + }, + }; +} + +export function threadUpdateEvent(view: ThreadView, unarchivedMembers?: Array): ThreadDispatchEvent { + return { + event: 'THREAD_UPDATE', + data: { + ...mapThreadToResponse(view), + ...internalThreadFields(view), + ...(unarchivedMembers ? {_fluxer_members: unarchivedMembers.map(mapThreadMemberToRpcResponse)} : {}), + }, + }; +} + +export function threadDeleteEvent(state: ThreadState, memberIds: Array = []): ThreadDispatchEvent { + return { + event: 'THREAD_DELETE', + data: { + id: state.threadId.toString(), + guild_id: state.guildId.toString(), + parent_id: state.parentId.toString(), + type: state.type, + ...(memberIds.length > 0 ? {_fluxer_member_ids: memberIds.map((id) => id.toString())} : {}), + }, + }; +} + +export function threadMembersUpdateEvent( + view: ThreadView, + change: {added?: Array; removedUserIds?: Array}, +): ThreadDispatchEvent { + const added = change.added ?? []; + const removed = change.removedUserIds ?? []; + return { + event: 'THREAD_MEMBERS_UPDATE', + data: { + id: view.state.threadId.toString(), + guild_id: view.state.guildId.toString(), + member_count: Math.min(view.state.memberCount, THREAD_MEMBER_COUNT_DISPLAY_CAP), + ...(added.length > 0 ? {added_members: added.map(mapThreadMemberToRpcResponse)} : {}), + ...(removed.length > 0 ? {removed_member_ids: removed.map((id) => id.toString())} : {}), + _fluxer_thread: {...mapThreadToResponse(view), ...internalThreadFields(view)}, + }, + }; +} + +export function threadMemberUpdateEvent(member: ThreadMember): ThreadDispatchEvent { + return { + event: 'THREAD_MEMBER_UPDATE', + data: { + ...mapThreadMemberToRpcResponse(member), + guild_id: member.guildId.toString(), + _fluxer_parent_id: member.parentId.toString(), + }, + }; +} + +export async function dispatchThreadEvents( + gatewayService: IGatewayService, + guildId: GuildID, + events: Array, +): Promise { + if (events.length === 0 || !guildActive(guildId)) return; + if (events.length === 1) { + await gatewayService.dispatchGuild({guildId, event: events[0]!.event, data: events[0]!.data}); + return; + } + await gatewayService.dispatchGuildMany({guildId, events}); +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadForumService.ts b/fluxer_api/src/api/channel/services/thread/ThreadForumService.ts new file mode 100644 index 000000000..f15e380d5 --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadForumService.ts @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type ChannelID, createUserID, type UserID} from '@app/api/BrandedTypes'; +import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; +import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials'; +import {mapThreadMemberToResponse, mapThreadToResponse} from '@app/api/channel/services/thread/ThreadMappers'; +import type {ThreadServiceContext} from '@app/api/channel/services/thread/ThreadServiceContext'; +import {loadThreadViews} from '@app/api/channel/services/thread/ThreadViews'; +import {enqueueThreadSearchBackfill} from '@app/api/channel/threads/ThreadJobs'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; +import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel'; +import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import {getThreadSearchService} from '@app/api/SearchFactory'; +import { + searchHitThreadIds, + searchThreadDocuments, + threadSearchCursor, +} from '@app/api/search/thread/ThreadSearchService'; +import { + ForumTagSettings, + THREAD_ONLY_CHANNEL_TYPES, + THREAD_PARENT_CHANNEL_TYPES, +} from '@fluxer/constants/src/ThreadConstants'; +import { + canListArchivedThreads, + isThreadModerator, + withImplicitThreadBits, +} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; +import {SearchIndexNotReadyError} from '@fluxer/errors/src/domains/channel/SearchIndexNotReadyError'; +import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; +import type { + ThreadPostDataResponse, + ThreadSearchQuery, + ThreadSearchResponse, +} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas'; +import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; + +const SEARCH_INDEX_RETRY_AFTER_SECONDS = 2; +const MAX_SEARCH_PRIVATE_THREAD_IDS = 1000; + +export class ThreadForumService { + constructor(private readonly ctx: ThreadServiceContext) {} + + async postData(params: { + viewer: ThreadViewer; + userId: UserID; + channelId: ChannelID; + threadIds: Array; + requestCache: RequestCache; + }): Promise { + const parentAuth = await this.authorizeParent(params.viewer, params.userId, params.channelId, true); + const guildId = parentAuth.channel.guildId!; + const {threads, channelData} = this.ctx.channelRepository; + const requestedIds = [...new Set(params.threadIds)]; + const states = (await threads.getStates(requestedIds)).filter((state) => state.parentId === parentAuth.channel.id); + const threadIds = states.map((state) => state.threadId); + const [channels, firstMessages] = await Promise.all([ + channelData.listChannels(threadIds), + this.ctx.threadMessageResponses.getFirstMessages({ + userId: params.userId, + guildId, + canReadMessageHistory: true, + threadIds, + }), + ]); + const ownerIds = [...new Set(channels.flatMap((channel) => (channel.ownerId ? [channel.ownerId] : [])))]; + const owners = new Map( + ( + await Promise.all( + ownerIds.map(async (ownerId) => { + const member = await this.ctx.guildRepository.getMember(guildId, createUserID(BigInt(ownerId))); + if (!member) return []; + return [ + [ + ownerId.toString(), + await mapGuildMemberToResponse(member, this.ctx.userCacheService, params.requestCache), + ] as const, + ]; + }), + ) + ).flat(), + ); + const ownerByThread = new Map(channels.map((channel) => [channel.id.toString(), channel.ownerId?.toString()])); + return { + threads: Object.fromEntries( + requestedIds.map((threadId) => { + const key = threadId.toString(); + const ownerId = ownerByThread.get(key); + return [ + key, + { + owner: ownerId ? (owners.get(ownerId) ?? null) : null, + first_message: firstMessages.get(key) ?? null, + }, + ]; + }), + ), + }; + } + + async search(params: { + viewer: ThreadViewer; + userId: UserID; + channelId: ChannelID; + query: ThreadSearchQuery; + }): Promise { + const parentAuth = await this.authorizeParent(params.viewer, params.userId, params.channelId, false); + const parent = parentAuth.channel; + const guildId = parent.guildId!; + if (!getThreadSearchService()) throw new FeatureTemporarilyDisabledError(); + const {threads} = this.ctx.channelRepository; + const marker = await threads.getGuildMarker(guildId); + if (!marker?.search_backfilled_at) { + await enqueueThreadSearchBackfill(guildId.toString()); + throw new SearchIndexNotReadyError(SEARCH_INDEX_RETRY_AFTER_SECONDS); + } + const actor = await this.ctx.parentActor(parentAuth, params.userId); + const moderator = isThreadModerator(withImplicitThreadBits(actor.permissions), actor); + const {query} = params; + const config = query.tag?.length ? await threads.getParentConfig(guildId, parent.id) : null; + const tagSetting = + query.tag_setting ?? + (config?.defaultTagSetting === ForumTagSettings.MATCH_ALL + ? ForumTagSettings.MATCH_ALL + : ForumTagSettings.MATCH_SOME); + const liveTagIds = new Set(config?.availableTags.map((tag) => tag.id.toString())); + const requestedTags = query.tag?.map((id) => id.toString()) ?? []; + const tagIds = requestedTags.filter((id) => liveTagIds.has(id)); + if ( + requestedTags.length > 0 && + (tagSetting === ForumTagSettings.MATCH_ALL ? tagIds.length < requestedTags.length : tagIds.length === 0) + ) { + return { + threads: [], + members: [], + has_more: false, + total_results: 0, + ...(THREAD_ONLY_CHANNEL_TYPES.has(parent.type) ? {first_messages: []} : {}), + }; + } + const privateThreadIds = + moderator || THREAD_ONLY_CHANNEL_TYPES.has(parent.type) + ? [] + : await threads.listJoinedPrivateThreadIds(params.userId, guildId, parent.id, MAX_SEARCH_PRIVATE_THREAD_IDS); + const joinedPrivate = new Set(privateThreadIds.map((id) => id.toString())); + const result = await searchThreadDocuments({ + name: query.name ?? '', + filters: { + guildId: guildId.toString(), + parentId: parent.id.toString(), + publicOnly: !moderator, + privateThreadIds: [...joinedPrivate], + archived: query.archived, + tagIds, + tagSetting, + after: query.min_id !== undefined ? threadSearchCursor(query.min_id) : undefined, + before: query.max_id !== undefined ? threadSearchCursor(query.max_id) : undefined, + sortBy: query.sort_by, + sortOrder: query.sort_order, + }, + limit: query.limit, + offset: query.offset, + }); + if (!result) throw new FeatureTemporarilyDisabledError(); + const order = searchHitThreadIds(result); + const states = (await threads.getStates(order)).filter( + (state) => + state.parentId === parent.id && (moderator || !state.isPrivate || joinedPrivate.has(state.threadId.toString())), + ); + const views = await loadThreadViews(this.ctx.channelRepository, states, [parent]); + const viewById = new Map(views.map((view) => [view.state.threadId.toString(), view])); + const ordered = order.flatMap((id) => { + const view = viewById.get(id.toString()); + return view ? [view] : []; + }); + const threadIds = ordered.map((view) => view.state.threadId); + const members = ( + await Promise.all(threadIds.map((threadId) => threads.getMember(threadId, params.userId))) + ).flatMap((member) => (member ? [mapThreadMemberToResponse(member, {self: true})] : [])); + const response: ThreadSearchResponse = { + threads: ordered.map((view) => mapThreadToResponse(view)), + members, + has_more: query.offset + result.hits.length < result.total, + total_results: result.total, + }; + if (THREAD_ONLY_CHANNEL_TYPES.has(parent.type)) { + const firstMessages = await this.ctx.threadMessageResponses.getFirstMessages({ + userId: params.userId, + guildId, + canReadMessageHistory: true, + threadIds, + }); + response.first_messages = threadIds.flatMap((threadId) => { + const message = firstMessages.get(threadId.toString()); + return message ? [message] : []; + }); + } + return response; + } + + private async authorizeParent( + viewer: ThreadViewer, + userId: UserID, + channelId: ChannelID, + threadOnly: boolean, + ): Promise { + const auth = await this.ctx.channelAuth.getChannelAuthenticated({ + userId, + channelId, + viewer, + }); + const allowed = threadOnly + ? THREAD_ONLY_CHANNEL_TYPES.has(auth.channel.type) + : THREAD_PARENT_CHANNEL_TYPES.has(auth.channel.type); + if (!auth.guild || !allowed) throw new InvalidChannelTypeError(); + assertThreadAllowed(canListArchivedThreads(await this.ctx.parentActor(auth, userId), {privateThreads: false})); + return auth; + } +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadInteractionGuards.ts b/fluxer_api/src/api/channel/services/thread/ThreadInteractionGuards.ts new file mode 100644 index 000000000..39ea2018d --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadInteractionGuards.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; +import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials'; +import { + ThreadPermissionFlags, + type ThreadWriteAction, + threadWriteBlock, +} from '@fluxer/constants/src/ThreadPermissionUtils'; + +export function assertThreadInteractionAllowed( + authChannel: AuthenticatedChannel, + action: ThreadWriteAction, + {ignoreTimeout = false}: {ignoreTimeout?: boolean} = {}, +): void { + if (!authChannel.thread) return; + const {actor} = authChannel.thread; + const effective = + ignoreTimeout && actor.timedOut + ? {...actor, timedOut: false, permissions: actor.permissions & ~ThreadPermissionFlags.MANAGE_THREADS} + : actor; + assertThreadAllowed(threadWriteBlock(action, effective)); +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadListService.ts b/fluxer_api/src/api/channel/services/thread/ThreadListService.ts new file mode 100644 index 000000000..e7a49ff53 --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadListService.ts @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; +import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; +import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials'; +import {mapThreadMemberToResponse, mapThreadToResponse} from '@app/api/channel/services/thread/ThreadMappers'; +import type {ThreadServiceContext} from '@app/api/channel/services/thread/ThreadServiceContext'; +import {loadThreadViews} from '@app/api/channel/services/thread/ThreadViews'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_PARENT_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; +import { + canListArchivedThreads, + isThreadModerator, + withImplicitThreadBits, +} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; +import type { + ActiveThreadsResponse, + ArchivedThreadsResponse, +} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; + +export class ThreadListService { + constructor(private readonly ctx: ThreadServiceContext) {} + + async listGuildActive(params: {userId: UserID; guildId: GuildID}): Promise { + const {guildId, userId} = params; + const guild = await this.ctx.gatewayService.getGuildData({guildId, userId}); + const [states, viewableChannelIds, joinedThreadIds] = await Promise.all([ + this.ctx.channelRepository.threads.listActiveThreads(guildId), + this.ctx.gatewayService.getViewableChannels({guildId, userId}), + this.ctx.channelRepository.threads.listJoinedThreadIds(userId, guildId), + ]); + const viewable = new Set(viewableChannelIds.map((id) => id.toString())); + const joined = new Set(joinedThreadIds); + const candidates = states.filter((state) => viewable.has(state.parentId.toString())); + const memberships = await this.memberships( + candidates.filter((state) => joined.has(state.threadId)), + userId, + ); + const privateParents = [ + ...new Set( + candidates + .filter((state) => state.isPrivate && !memberships.has(state.threadId)) + .map((state) => state.parentId), + ), + ]; + const isOwner = guild.owner_id === userId.toString(); + const timedOut = privateParents.length > 0 && !isOwner && (await this.isTimedOut(guildId, userId)); + const moderatedParents = new Set(); + await Promise.all( + privateParents.map(async (parentId) => { + const permissions = await this.ctx.gatewayService.getUserPermissions({guildId, userId, channelId: parentId}); + if (isThreadModerator(withImplicitThreadBits(permissions), {isOwner, timedOut})) { + moderatedParents.add(parentId); + } + }), + ); + const visible = candidates + .filter((state) => !state.isPrivate || memberships.has(state.threadId) || moderatedParents.has(state.parentId)) + .sort((a, b) => (a.threadId > b.threadId ? -1 : a.threadId < b.threadId ? 1 : 0)); + return this.respond(visible, memberships, userId); + } + + private async isTimedOut(guildId: GuildID, userId: UserID): Promise { + const until = (await this.ctx.guildRepository.getMember(guildId, userId))?.communicationDisabledUntil; + return until != null && until.getTime() > Date.now(); + } + + async listGuildThreadsForAdmin(guildId: GuildID): Promise { + const threadIds = await this.ctx.channelRepository.threads.listGuildThreadIds(guildId); + const states = await this.ctx.channelRepository.threads.getStates(threadIds); + const views = await loadThreadViews( + this.ctx.channelRepository, + states.sort((a, b) => (a.threadId > b.threadId ? -1 : a.threadId < b.threadId ? 1 : 0)), + ); + return views.map((view) => mapThreadToResponse(view)); + } + + async listPublicArchived(params: { + viewer: ThreadViewer; + userId: UserID; + channelId: ChannelID; + before?: Date; + limit: number; + }): Promise { + const auth = await this.authorizeParent(params.viewer, params.userId, params.channelId, false); + const page = await this.ctx.channelRepository.threads.listArchivedThreads(auth.channel.id, false, { + before: params.before, + limit: params.limit, + }); + return this.respondPage(page.threads, page.hasMore, params.userId, auth); + } + + async listPrivateArchived(params: { + viewer: ThreadViewer; + userId: UserID; + channelId: ChannelID; + before?: Date; + limit: number; + }): Promise { + const auth = await this.authorizeParent(params.viewer, params.userId, params.channelId, true); + const page = await this.ctx.channelRepository.threads.listArchivedThreads(auth.channel.id, true, { + before: params.before, + limit: params.limit, + }); + return this.respondPage(page.threads, page.hasMore, params.userId, auth); + } + + async listJoinedPrivateArchived(params: { + viewer: ThreadViewer; + userId: UserID; + channelId: ChannelID; + before?: ChannelID; + limit: number; + }): Promise { + const auth = await this.authorizeParent(params.viewer, params.userId, params.channelId, false, true); + const page = await this.ctx.channelRepository.threads.listJoinedPrivateArchivedThreads( + params.userId, + auth.channel.guildId!, + auth.channel.id, + {before: params.before, limit: params.limit}, + ); + return this.respondPage(page.threads, page.hasMore, params.userId, auth); + } + + private async authorizeParent( + viewer: ThreadViewer, + userId: UserID, + channelId: ChannelID, + privateThreads: boolean, + textOnly = privateThreads, + ): Promise { + const auth = await this.ctx.channelAuth.getChannelAuthenticated({ + userId, + channelId, + viewer, + skipNsfwValidation: true, + }); + const {channel} = auth; + const allowed = textOnly ? channel.type === ChannelTypes.GUILD_TEXT : THREAD_PARENT_CHANNEL_TYPES.has(channel.type); + if (!auth.guild || !allowed) throw new InvalidChannelTypeError(); + assertThreadAllowed(canListArchivedThreads(await this.ctx.parentActor(auth, userId), {privateThreads})); + return auth; + } + + private async memberships(states: Array, userId: UserID) { + const members = await Promise.all( + states.map((state) => this.ctx.channelRepository.threads.getMember(state.threadId, userId)), + ); + return new Map(members.flatMap((member) => (member ? [[member.threadId, member] as const] : []))); + } + + private async respond( + states: Array, + memberships: Awaited>, + userId: UserID, + parents: Array = [], + ): Promise { + const views = await loadThreadViews(this.ctx.channelRepository, states, parents); + return { + threads: views.map((view) => mapThreadToResponse(view)), + members: views.flatMap((view) => { + const member = memberships.get(view.state.threadId); + return member ? [mapThreadMemberToResponse(member, {self: member.userId === userId})] : []; + }), + }; + } + + private async respondPage( + states: Array, + hasMore: boolean, + userId: UserID, + auth: AuthenticatedChannel, + ): Promise { + const memberships = await this.memberships(states, userId); + return {...(await this.respond(states, memberships, userId, [auth.channel])), has_more: hasMore}; + } +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadMappers.ts b/fluxer_api/src/api/channel/services/thread/ThreadMappers.ts new file mode 100644 index 000000000..7b017fbe2 --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadMappers.ts @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Channel} from '@app/api/models/Channel'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import type {ThreadStats} from '@app/api/models/ThreadStats'; +import {THREAD_MEMBER_COUNT_DISPLAY_CAP, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {ThreadMemberResponse, ThreadMemberRpcResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; +import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; + +export interface ThreadView { + channel: Channel; + state: ThreadState; + stats: ThreadStats; + parentType: number | null; +} + +export function mapThreadMemberToResponse( + member: ThreadMember, + options: {self: boolean; guildMember?: GuildMemberResponse; withIds?: boolean}, +): ThreadMemberResponse { + const response: ThreadMemberResponse = { + ...(options.withIds === false ? {} : {id: member.threadId.toString(), user_id: member.userId.toString()}), + join_timestamp: member.joinTimestamp.toISOString(), + flags: member.flags, + }; + if (options.self) { + response.muted = member.muted; + response.mute_config = member.muteConfig + ? { + end_time: member.muteConfig.endTime?.toISOString() ?? null, + selected_time_window: member.muteConfig.selectedTimeWindow ?? 0, + } + : null; + } + if (options.guildMember) response.member = options.guildMember; + return response; +} + +export function mapThreadMemberToRpcResponse(member: ThreadMember): ThreadMemberRpcResponse { + return { + id: member.threadId.toString(), + user_id: member.userId.toString(), + join_timestamp: member.joinTimestamp.toISOString(), + flags: member.flags, + muted: member.muted, + mute_config: member.muteConfig + ? { + end_time: member.muteConfig.endTime?.toISOString() ?? null, + selected_time_window: member.muteConfig.selectedTimeWindow ?? 0, + } + : null, + }; +} + +export function mapThreadToResponse(view: ThreadView, viewerMember?: ThreadMember | null): ThreadChannelResponse { + const {channel, state, stats} = view; + const createdAt = state.createdAt.toISOString(); + const response: ThreadChannelResponse = { + id: channel.id.toString(), + type: channel.type, + guild_id: channel.guildId?.toString(), + parent_id: channel.parentId?.toString() ?? null, + owner_id: channel.ownerId?.toString() ?? null, + name: channel.name ?? null, + last_message_id: channel.lastMessageId?.toString() ?? null, + last_pin_timestamp: channel.lastPinTimestamp?.toISOString() ?? null, + rate_limit_per_user: channel.rateLimitPerUser ?? 0, + flags: state.flags, + thread_metadata: { + archived: state.archived, + auto_archive_duration: state.autoArchiveDuration, + archive_timestamp: state.archiveTimestamp?.toISOString() ?? createdAt, + locked: state.locked, + ...(state.isPrivate ? {invitable: state.invitable ?? true} : {}), + create_timestamp: createdAt, + }, + message_count: stats.messageCount, + total_message_sent: stats.totalMessageSent, + member_count: Math.min(state.memberCount, THREAD_MEMBER_COUNT_DISPLAY_CAP), + }; + if (view.parentType !== null && THREAD_ONLY_CHANNEL_TYPES.has(view.parentType)) { + response.applied_tags = state.appliedTags.map((tag) => tag.toString()); + response.member_ids_preview = state.memberIdsPreview.map((id) => id.toString()); + } + if (viewerMember) { + response.member = mapThreadMemberToResponse(viewerMember, {self: true}); + } + return response; +} + +export function mapThreadToRpcResponse(view: ThreadView): ThreadChannelResponse { + return { + ...mapThreadToResponse(view), + member_ids_preview: view.state.memberIdsPreview.map((id) => id.toString()), + }; +} + +export function serializeThreadForAudit(view: ThreadView): Record { + const {channel, state} = view; + return { + name: channel.name, + type: channel.type, + archived: state.archived, + locked: state.locked, + auto_archive_duration: state.autoArchiveDuration, + rate_limit_per_user: channel.rateLimitPerUser ?? 0, + flags: state.flags, + ...(state.isPrivate ? {invitable: state.invitable ?? true} : {}), + ...(view.parentType !== null && THREAD_ONLY_CHANNEL_TYPES.has(view.parentType) + ? {applied_tags: state.appliedTags.map((tag) => tag.toString())} + : {}), + }; +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadMemberService.ts b/fluxer_api/src/api/channel/services/thread/ThreadMemberService.ts new file mode 100644 index 000000000..bfa976f4f --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadMemberService.ts @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type ChannelID, createMessageID, type UserID} from '@app/api/BrandedTypes'; +import type {AuthenticatedChannel, AuthenticatedThread} from '@app/api/channel/services/AuthenticatedChannel'; +import {dispatchMessageCreateBroadcast} from '@app/api/channel/services/message/MessageGatewayDispatch'; +import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials'; +import {dispatchThreadEvents, threadMembersUpdateEvent} from '@app/api/channel/services/thread/ThreadDispatch'; +import {mapThreadMemberToResponse} from '@app/api/channel/services/thread/ThreadMappers'; +import type {ThreadServiceContext} from '@app/api/channel/services/thread/ThreadServiceContext'; +import {loadThreadView} from '@app/api/channel/services/thread/ThreadViews'; +import {recipientActive, type ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; +import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel'; +import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; +import type {User} from '@app/api/models/User'; +import {MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import { + canAddThreadMember, + canJoinThread, + canLeave, + canRemoveThreadMember, + isThreadModerator, + ThreadPermissionFlags, + withImplicitThreadBits, +} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; +import {UnknownThreadMemberError} from '@fluxer/errors/src/domains/channel/UnknownThreadMemberError'; +import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError'; +import type {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; +import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; + +type ThreadAuth = AuthenticatedChannel & {thread: AuthenticatedThread}; + +export class ThreadMemberService { + constructor(private readonly ctx: ThreadServiceContext) {} + + async authorizeThread(viewer: ThreadViewer, userId: UserID, channelId: ChannelID): Promise { + const auth = await this.ctx.channelAuth.getChannelAuthenticated({ + userId, + channelId, + viewer, + skipNsfwValidation: true, + }); + if (!auth.thread) throw new InvalidChannelTypeError(); + return auth as ThreadAuth; + } + + async join(params: {viewer: ThreadViewer; user: User; channelId: ChannelID}): Promise { + const auth = await this.authorizeThread(params.viewer, params.user.id, params.channelId); + assertThreadAllowed(canJoinThread(auth.thread.actor)); + if (auth.thread.state.locked) auth.thread.enforceMfa(ThreadPermissionFlags.MANAGE_THREADS); + if (auth.thread.member) return; + await this.addMember(auth, params.user.id); + } + + async add(params: {viewer: ThreadViewer; user: User; channelId: ChannelID; targetId: UserID}): Promise { + const auth = await this.authorizeThread(params.viewer, params.user.id, params.channelId); + const {thread} = auth; + const guildId = thread.state.guildId; + const targetMember = await this.ctx.guildRepository.getMember(guildId, params.targetId); + if (!targetMember) throw new UnknownGuildMemberError(); + const targetPermissions = await this.ctx.gatewayService.getUserPermissions({ + guildId, + userId: params.targetId, + channelId: thread.parent.id, + }); + const targetIsOwner = auth.guild?.owner_id === params.targetId.toString(); + const targetUser = await this.ctx.userRepository.findUnique(params.targetId); + const targetIsModerator = isThreadModerator(withImplicitThreadBits(targetPermissions), { + isOwner: targetIsOwner, + timedOut: + !targetIsOwner && + targetMember.communicationDisabledUntil !== null && + targetMember.communicationDisabledUntil.getTime() > Date.now(), + }); + const canViewParent = + (targetIsOwner || (withImplicitThreadBits(targetPermissions) & Permissions.VIEW_CHANNEL) !== 0n) && + targetUser !== null && + recipientActive(guildId, params.targetId, targetUser.isBot); + assertThreadAllowed(canAddThreadMember(thread.actor, {canViewParent, isModerator: targetIsModerator})); + if (thread.state.locked || (thread.state.isPrivate && !(thread.state.invitable ?? true) && !targetIsModerator)) { + thread.enforceMfa(ThreadPermissionFlags.MANAGE_THREADS); + } + const existing = await this.ctx.channelRepository.threads.getMember(thread.state.threadId, params.targetId); + if (existing) return; + const added = await this.addMember(auth, params.targetId); + if (added && params.targetId !== params.user.id) { + await this.sendRecipientMessage(auth, params.user.id, params.targetId, MessageTypes.RECIPIENT_ADD); + } + } + + async leave(params: {viewer: ThreadViewer; user: User; channelId: ChannelID}): Promise { + const auth = await this.authorizeThread(params.viewer, params.user.id, params.channelId); + assertThreadAllowed(canLeave(auth.thread.actor)); + await this.removeMember(auth, params.user.id); + } + + async remove(params: {viewer: ThreadViewer; user: User; channelId: ChannelID; targetId: UserID}): Promise { + if (params.targetId === params.user.id) { + await this.leave(params); + return; + } + const auth = await this.authorizeThread(params.viewer, params.user.id, params.channelId); + const {thread} = auth; + assertThreadAllowed(canRemoveThreadMember(thread.actor)); + if (!(thread.state.isPrivate && thread.actor.isThreadOwner)) { + thread.enforceMfa(ThreadPermissionFlags.MANAGE_THREADS); + } + const existing = await this.ctx.channelRepository.threads.getMember(thread.state.threadId, params.targetId); + if (!existing) throw new UnknownThreadMemberError(); + if (!(await this.removeMember(auth, params.targetId))) return; + await this.sendRecipientMessage(auth, params.user.id, params.targetId, MessageTypes.RECIPIENT_REMOVE); + } + + async list(params: { + viewer: ThreadViewer; + user: User; + channelId: ChannelID; + after?: UserID; + limit: number; + withMember: boolean; + requestCache: RequestCache; + }): Promise> { + const auth = await this.authorizeThread(params.viewer, params.user.id, params.channelId); + const members = await this.ctx.channelRepository.threads.listMembers(auth.thread.state.threadId, { + after: params.after, + limit: params.limit, + }); + return this.mapMembers(auth, members, params.user.id, params.withMember, params.requestCache); + } + + async get(params: { + viewer: ThreadViewer; + user: User; + channelId: ChannelID; + targetId: UserID; + withMember: boolean; + requestCache: RequestCache; + }): Promise { + const auth = await this.authorizeThread(params.viewer, params.user.id, params.channelId); + const member = await this.ctx.channelRepository.threads.getMember(auth.thread.state.threadId, params.targetId); + if (!member) throw new UnknownThreadMemberError(); + const [response] = await this.mapMembers(auth, [member], params.user.id, params.withMember, params.requestCache); + return response!; + } + + private async mapMembers( + auth: ThreadAuth, + members: Array, + viewerId: UserID, + withMember: boolean, + requestCache: RequestCache, + ): Promise> { + const guildMembers = new Map(); + if (withMember) { + const loaded = await Promise.all( + members.map((member) => this.ctx.guildRepository.getMember(auth.thread.state.guildId, member.userId)), + ); + for (const guildMember of loaded) { + if (!guildMember) continue; + guildMembers.set( + guildMember.userId, + await mapGuildMemberToResponse(guildMember, this.ctx.userCacheService, requestCache), + ); + } + } + return members.map((member) => + mapThreadMemberToResponse(member, { + self: member.userId === viewerId, + guildMember: guildMembers.get(member.userId), + }), + ); + } + + private async addMember(auth: ThreadAuth, userId: UserID): Promise { + const threads = this.ctx.channelRepository.threads; + const result = await threads.addMembers(auth.thread.state.threadId, [{userId, flags: 0}]); + if (!result || result.added.length === 0) return false; + const view = await loadThreadView(this.ctx.channelRepository, auth.channel, result.state, auth.thread.parent); + await dispatchThreadEvents(this.ctx.gatewayService, result.state.guildId, [ + threadMembersUpdateEvent(view, {added: result.added}), + ]); + return true; + } + + private async removeMember(auth: ThreadAuth, userId: UserID): Promise { + const result = await this.ctx.channelRepository.threads.removeMembers(auth.thread.state.threadId, [userId]); + if (result.removed.length === 0 || !result.state) return false; + const view = await loadThreadView(this.ctx.channelRepository, auth.channel, result.state, auth.thread.parent); + await dispatchThreadEvents(this.ctx.gatewayService, result.state.guildId, [ + threadMembersUpdateEvent(view, {removedUserIds: result.removed.map((member) => member.userId)}), + ]); + return true; + } + + private async sendRecipientMessage( + auth: ThreadAuth, + actorId: UserID, + targetId: UserID, + type: typeof MessageTypes.RECIPIENT_ADD | typeof MessageTypes.RECIPIENT_REMOVE, + ): Promise { + const messageId = createMessageID(await this.ctx.snowflakeService.generateForChannel(auth.channel.id)); + const message = await this.ctx.messagePersistence.createSystemMessage({ + messageId, + channelId: auth.channel.id, + userId: actorId, + type, + guildId: auth.channel.guildId, + mentionUserIds: [targetId], + }); + await dispatchMessageCreateBroadcast({gatewayService: this.ctx.gatewayService, channel: auth.channel, message}); + } +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadMemberSettingsService.ts b/fluxer_api/src/api/channel/services/thread/ThreadMemberSettingsService.ts new file mode 100644 index 000000000..4c5cdfa13 --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadMemberSettingsService.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, UserID} from '@app/api/BrandedTypes'; +import {dispatchThreadEvents, threadMemberUpdateEvent} from '@app/api/channel/services/thread/ThreadDispatch'; +import {mapThreadMemberToResponse} from '@app/api/channel/services/thread/ThreadMappers'; +import type {ThreadServiceContext} from '@app/api/channel/services/thread/ThreadServiceContext'; +import type {MuteConfig} from '@app/api/database/types/UserTypes'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; +import {isValidThreadMemberSettingsFlags, ThreadMemberFlags} from '@fluxer/constants/src/ThreadConstants'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; +import {InvalidThreadNotificationSettingsError} from '@fluxer/errors/src/domains/channel/InvalidThreadNotificationSettingsError'; +import {UnknownThreadMemberError} from '@fluxer/errors/src/domains/channel/UnknownThreadMemberError'; +import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; +import type {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; +import type {ThreadMemberSettingsRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas'; + +const SETTINGS_CAS_ATTEMPTS = 3; + +function sameMuteConfig(a: MuteConfig | null, b: MuteConfig | null): boolean { + if (a === null || b === null) return a === b; + return ( + (a.end_time?.getTime() ?? null) === (b.end_time?.getTime() ?? null) && + a.selected_time_window === b.selected_time_window + ); +} + +export class ThreadMemberSettingsService { + constructor(private readonly ctx: ThreadServiceContext) {} + + async update(params: { + viewer: ThreadViewer; + userId: UserID; + channelId: ChannelID; + data: ThreadMemberSettingsRequest; + }): Promise { + const {data, userId} = params; + if (data.flags !== undefined && !isValidThreadMemberSettingsFlags(data.flags)) { + throw new InvalidThreadNotificationSettingsError(); + } + const auth = await this.ctx.channelAuth.getChannelAuthenticated({ + userId, + channelId: params.channelId, + viewer: params.viewer, + skipNsfwValidation: true, + }); + if (!auth.thread) throw new InvalidChannelTypeError(); + const threadId = auth.thread.state.threadId; + for (let attempt = 0; attempt < SETTINGS_CAS_ATTEMPTS; attempt++) { + const member = + (attempt === 0 ? auth.thread.member : null) ?? + (await this.ctx.channelRepository.threads.getMember(threadId, userId)); + if (!member) throw new UnknownThreadMemberError(); + const flags = + data.flags === undefined ? member.flags : (member.flags & ThreadMemberFlags.HAS_INTERACTED) | data.flags; + const muted = data.muted ?? member.muted; + const current = member.muteConfig?.toMuteConfig() ?? null; + const muteConfig = + data.mute_config === undefined + ? current + : data.mute_config === null + ? null + : { + end_time: data.mute_config.end_time ?? null, + selected_time_window: data.mute_config.selected_time_window, + }; + if (flags === member.flags && muted === member.muted && sameMuteConfig(muteConfig, current)) return null; + const updated = await this.ctx.channelRepository.threads.updateMemberSettings(member, { + flags, + muted, + ...(data.mute_config === undefined ? {} : {muteConfig}), + }); + if (!updated) continue; + await this.dispatch(updated); + return mapThreadMemberToResponse(updated, {self: true}); + } + throw new ServiceUnavailableError(); + } + + private async dispatch(member: ThreadMember): Promise { + await dispatchThreadEvents(this.ctx.gatewayService, member.guildId, [threadMemberUpdateEvent(member)]); + } +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadModifyService.ts b/fluxer_api/src/api/channel/services/thread/ThreadModifyService.ts new file mode 100644 index 000000000..d62465c5a --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadModifyService.ts @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createMessageID, type UserID} from '@app/api/BrandedTypes'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import type {ThreadStatePatch} from '@app/api/channel/repositories/IThreadRepository'; +import type {AuthenticatedChannel, AuthenticatedThread} from '@app/api/channel/services/AuthenticatedChannel'; +import type {ChannelUtilsService} from '@app/api/channel/services/channel_data/ChannelUtilsService'; +import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService'; +import {resolveAppliedTags} from '@app/api/channel/services/thread/ForumTagRules'; +import {clearThreadSourceFlag} from '@app/api/channel/services/thread/ThreadDeletionService'; +import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials'; +import { + dispatchThreadEvents, + threadDeleteEvent, + threadUpdateEvent, +} from '@app/api/channel/services/thread/ThreadDispatch'; +import {serializeThreadForAudit, type ThreadView} from '@app/api/channel/services/thread/ThreadMappers'; +import {loadThreadView} from '@app/api/channel/services/thread/ThreadViews'; +import {enqueueThreadSearchSync} from '@app/api/channel/threads/ThreadJobs'; +import {threadsArchivedTotal} from '@app/api/channel/threads/ThreadMetrics'; +import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; +import {contentModerationService} from '@app/api/infrastructure/ContentModerationService'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; +import {Logger} from '@app/api/Logger'; +import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import {getKVThreadAutoArchiveQueue} from '@app/api/middleware/ServiceSingletons'; +import type {Channel} from '@app/api/models/Channel'; +import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup'; +import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService'; +import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; +import {MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import { + ChannelFlags, + MAX_ACTIVE_THREADS_PER_GUILD, + MAX_PINNED_THREADS_PER_FORUM, + MAX_THREAD_MEMBERS, + settableChannelFlags, +} from '@fluxer/constants/src/ThreadConstants'; +import { + canDeleteThread, + canSetTags, + type ThreadPatch, + ThreadPermissionFlags, + threadPatchRequirement, +} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; +import {MaxActiveThreadsError} from '@fluxer/errors/src/domains/channel/MaxActiveThreadsError'; +import {MaxPinnedThreadsInForumError} from '@fluxer/errors/src/domains/channel/MaxPinnedThreadsInForumError'; +import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; +import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; +import type {ChannelUpdateThreadRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas'; +import type {ICacheService} from '@pkgs/cache/src/ICacheService'; +import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService'; + +interface ThreadModifyServiceDeps { + channelRepository: IChannelRepositoryAggregate; + gatewayService: IGatewayService; + guildAuditLogService: GuildAuditLogService; + rateLimitService: IRateLimitService; + cacheService: ICacheService; + snowflakeService: ISnowflakeService; + messagePersistence: MessagePersistenceService; + utils: ChannelUtilsService; +} + +type ThreadUpdateData = Omit & { + invitable?: boolean | null; + applied_tags?: Array; +}; + +function changed(value: T | undefined, current: T | null): T | undefined { + return value === current ? undefined : value; +} + +function requiresModerator(patch: ThreadPatch, thread: AuthenticatedThread): boolean { + const {actor, state} = thread; + if (patch.locked !== undefined && patch.locked !== state.locked && !(patch.locked && actor.isThreadOwner)) + return true; + if (patch.rate_limit_per_user !== undefined || (patch.flags !== undefined && patch.flags !== state.flags)) + return true; + if (patch.archived === false && state.archived && state.locked) return true; + const ownerAction = + patch.name !== undefined || + patch.auto_archive_duration !== undefined || + patch.invitable !== undefined || + (patch.archived === true && !state.archived); + return ownerAction && !actor.isThreadOwner; +} + +export class ThreadModifyService { + constructor(private readonly deps: ThreadModifyServiceDeps) {} + + get repository(): IChannelRepositoryAggregate { + return this.deps.channelRepository; + } + + async updateThread({ + authChannel, + userId, + data: input, + requestCache, + auditLogReason, + }: { + authChannel: AuthenticatedChannel; + userId: UserID; + data: ThreadUpdateData; + requestCache: RequestCache; + auditLogReason: string | null; + }): Promise { + const data = { + ...input, + archived: input.archived ?? undefined, + locked: input.locked ?? undefined, + rate_limit_per_user: input.rate_limit_per_user === null ? 0 : input.rate_limit_per_user, + invitable: input.invitable ?? undefined, + }; + const thread = authChannel.thread; + const channel = authChannel.channel; + if (!thread || channel.guildId === null) throw new UnknownChannelError(); + const {state, parent} = thread; + if (data.flags !== undefined && (data.flags & ~settableChannelFlags(channel.type, parent.type)) !== 0) { + throw InputValidationError.fromCode('flags', ValidationErrorCodes.INVALID_FORMAT); + } + if (data.applied_tags !== undefined && !parent.isThreadOnly()) throw new InvalidChannelTypeError(); + const patch: ThreadPatch = { + name: changed(data.name, channel.name), + archived: data.archived, + auto_archive_duration: changed(data.auto_archive_duration, state.autoArchiveDuration), + locked: data.locked, + invitable: state.isPrivate ? changed(data.invitable, state.invitable) : undefined, + rate_limit_per_user: changed(data.rate_limit_per_user, channel.rateLimitPerUser), + flags: data.flags, + applied_tags: data.applied_tags, + }; + assertThreadAllowed(threadPatchRequirement(patch, state.flags, thread.actor)); + const appliedTags = + data.applied_tags !== undefined + ? await this.resolvePatchedTags( + thread, + data.applied_tags.map((id) => BigInt(id)), + ) + : undefined; + if (requiresModerator(patch, thread)) thread.enforceMfa(ThreadPermissionFlags.MANAGE_THREADS); + const pinning = + data.flags !== undefined && (data.flags & ChannelFlags.PINNED) !== 0 && !state.isPinned && data.archived !== true; + if (pinning && !(await this.deps.channelRepository.threads.claimForumPin(parent.id, channel.id))) { + throw new MaxPinnedThreadsInForumError(MAX_PINNED_THREADS_PER_FORUM); + } + if (patch.archived === false && state.archived) { + const activeCount = await this.deps.channelRepository.threads.countActiveThreads(state.guildId); + if (activeCount >= MAX_ACTIVE_THREADS_PER_GUILD) throw new MaxActiveThreadsError(MAX_ACTIVE_THREADS_PER_GUILD); + } + if (data.name !== undefined) { + contentModerationService.scanText(data.name, { + userId, + guildId: channel.guildId, + channelId: channel.id, + messageId: null, + surface: 'profile_field', + }); + } + const before = await loadThreadView(this.deps.channelRepository, channel, state, parent); + const renamed = data.name !== undefined && data.name !== channel.name; + const rateChanged = data.rate_limit_per_user !== undefined && data.rate_limit_per_user !== channel.rateLimitPerUser; + const statePatch: ThreadStatePatch = { + archived: data.archived, + locked: data.locked, + auto_archive_duration: data.auto_archive_duration, + invitable: state.isPrivate ? data.invitable : undefined, + flags: data.flags, + applied_tags: appliedTags, + }; + const transition = await this.deps.channelRepository.threads.updateState(channel.id, (current) => + data.auto_archive_duration !== undefined && data.auto_archive_duration !== current.autoArchiveDuration + ? {...statePatch, archive_timestamp: new Date()} + : statePatch, + ); + if (!transition) { + if (pinning) await this.deps.channelRepository.threads.releaseForumPin(parent.id, channel.id); + throw new UnknownChannelError(); + } + const updatedChannel = + renamed || rateChanged + ? await this.deps.channelRepository.channelData.upsert( + { + ...channel.toRow(), + name: renamed ? data.name! : channel.name, + rate_limit_per_user: rateChanged ? data.rate_limit_per_user! : channel.rateLimitPerUser, + }, + channel.toRow(), + ) + : channel; + const next = transition.state; + if (rateChanged) await this.clearThreadSlowmode(channel); + enqueueThreadSearchSync(channel.id); + await this.syncArchiveQueue(updatedChannel, transition.previous.archived, next); + const after = await loadThreadView(this.deps.channelRepository, updatedChannel, next, parent); + const changes = this.deps.guildAuditLogService.computeChanges( + serializeThreadForAudit(before), + serializeThreadForAudit(after), + ); + if (changes.length > 0) { + const unarchivedMembers = + transition.previous.archived && !next.archived + ? await this.deps.channelRepository.threads.listMembers(channel.id, {limit: MAX_THREAD_MEMBERS}) + : undefined; + await dispatchThreadEvents(this.deps.gatewayService, next.guildId, [threadUpdateEvent(after, unarchivedMembers)]); + await this.recordAudit({ + view: after, + userId, + action: AuditLogActionType.THREAD_UPDATE, + auditLogReason, + changes, + }); + } + if (renamed) await this.sendNameChangeMessage(updatedChannel, userId, requestCache); + return updatedChannel; + } + + private async resolvePatchedTags(thread: AuthenticatedThread, requested: Array): Promise> { + const {state, parent, actor} = thread; + const config = await this.deps.channelRepository.threads.getParentConfig(state.guildId, parent.id); + const available = new Map((config?.availableTags ?? []).map((tag) => [tag.id, tag])); + const previous = new Set(state.appliedTags); + const touchesModeratedTag = [ + ...requested.filter((id) => !previous.has(id)), + ...state.appliedTags.filter((id) => !requested.includes(id)), + ].some((id) => available.get(id)?.moderated === true); + assertThreadAllowed(canSetTags(actor, {touchesModeratedTag})); + return resolveAppliedTags({config, requested, previous: state.appliedTags, moderator: thread.isModerator}); + } + + async deleteThread({ + authChannel, + userId, + requestCache, + auditLogReason, + }: { + authChannel: AuthenticatedChannel; + userId: UserID; + requestCache: RequestCache; + auditLogReason: string | null; + }): Promise { + const thread = authChannel.thread; + const channel = authChannel.channel; + if (!thread || channel.guildId === null) throw new UnknownChannelError(); + assertThreadAllowed(canDeleteThread(thread.actor)); + thread.enforceMfa(ThreadPermissionFlags.MANAGE_THREADS); + const view = await loadThreadView(this.deps.channelRepository, channel, thread.state, thread.parent); + const members = await this.deps.channelRepository.threads.listMembers(channel.id, {limit: MAX_THREAD_MEMBERS}); + await this.deps.utils.purgeChannelAttachments(channel); + await deleteChannelMessageSearchDocuments(channel.id, {context: {source: 'thread_delete'}}); + await this.deps.channelRepository.threads.purgeThread(channel.id); + await getKVThreadAutoArchiveQueue().remove(thread.state.guildId, channel.id); + await deleteThreadSearchDocuments([channel.id]); + requestCache.channels.delete(channel.id); + await dispatchThreadEvents(this.deps.gatewayService, thread.state.guildId, [ + threadDeleteEvent( + thread.state, + members.map((member) => member.userId), + ), + ...(await clearThreadSourceFlag( + this.deps.channelRepository, + this.deps.cacheService, + thread.state, + thread.parent, + )), + ]); + await this.recordAudit({ + view, + userId, + action: AuditLogActionType.THREAD_DELETE, + auditLogReason, + changes: this.deps.guildAuditLogService.computeChanges(serializeThreadForAudit(view), null), + }); + } + + private async syncArchiveQueue(channel: Channel, wasArchived: boolean, next: ThreadView['state']): Promise { + const queue = getKVThreadAutoArchiveQueue(); + if (next.archived) { + if (!wasArchived) threadsArchivedTotal.inc('reason="manual"'); + await queue.remove(next.guildId, next.threadId); + return; + } + await queue.schedule(next, channel.lastMessageId); + } + + private async clearThreadSlowmode(channel: Channel): Promise { + try { + await this.deps.rateLimitService.clearLimitsByIdentifierPrefix(`slowmode:${channel.id}:`); + } catch (error) { + Logger.error({error, channelId: channel.id.toString()}, 'Failed to clear thread slowmode state'); + } + } + + private async sendNameChangeMessage(channel: Channel, userId: UserID, requestCache: RequestCache): Promise { + const messageId = createMessageID(await this.deps.snowflakeService.generateForChannel(channel.id)); + const message = await this.deps.messagePersistence.createSystemMessage({ + messageId, + channelId: channel.id, + userId, + type: MessageTypes.CHANNEL_NAME_CHANGE, + content: channel.name, + guildId: channel.guildId, + }); + await this.deps.utils.dispatchMessageCreate({channel, message, requestCache}); + } + + private async recordAudit(params: { + view: ThreadView; + userId: UserID; + action: AuditLogActionType; + auditLogReason: string | null; + changes: ReturnType; + }): Promise { + try { + await this.deps.guildAuditLogService + .createBuilder(params.view.state.guildId, params.userId) + .withAction(params.action, params.view.channel.id.toString()) + .withReason(params.auditLogReason) + .withMetadata({type: params.view.channel.type.toString()}) + .withChanges(params.changes) + .commit(); + } catch (error) { + Logger.error( + {error, guildId: params.view.state.guildId.toString(), action: params.action}, + 'Failed to record thread audit log', + ); + } + } +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadParentConversion.ts b/fluxer_api/src/api/channel/services/thread/ThreadParentConversion.ts new file mode 100644 index 000000000..4a2562b58 --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadParentConversion.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID} from '@app/api/BrandedTypes'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import {type ThreadDispatchEvent, threadUpdateEvent} from '@app/api/channel/services/thread/ThreadDispatch'; +import {loadThreadViews} from '@app/api/channel/services/thread/ThreadViews'; +import {everEnabled, isTainted} from '@app/api/experiment/ChannelThreadsGate'; +import {Logger} from '@app/api/Logger'; +import type {Channel} from '@app/api/models/Channel'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {PUBLIC_THREAD_CHANNEL_TYPES, publicThreadTypeFor} from '@fluxer/constants/src/ThreadConstants'; +import {ChannelHasThreadsError} from '@fluxer/errors/src/domains/channel/ChannelHasThreadsError'; + +const RETYPE_CONCURRENCY = 8; +export const PARENT_CONVERSION_LOCK_TTL_SECONDS = 30; + +export type ParentThreads = Array<{threadId: ChannelID; type: number}>; + +export async function loadConvertibleParentThreads( + channelRepository: IChannelRepositoryAggregate, + guildId: GuildID, + parent: Channel, + nextType: number, +): Promise { + if (!everEnabled() || !(await isTainted(guildId, {fresh: true}))) return []; + const threads = await channelRepository.threads.listParentThreads(parent.id); + if ( + nextType === ChannelTypes.GUILD_ANNOUNCEMENT && + threads.some((thread) => thread.type === ChannelTypes.PRIVATE_THREAD) + ) { + throw new ChannelHasThreadsError(); + } + return threads; +} + +export async function retypeParentThreads( + channelRepository: IChannelRepositoryAggregate, + threads: ParentThreads, + parentType: number, + writeParent: () => Promise, +): Promise<{parent: T; active: Array}> { + const type = publicThreadTypeFor(parentType); + const pending = threads.filter((thread) => thread.type !== type && PUBLIC_THREAD_CHANNEL_TYPES.has(thread.type)); + const retyped: ParentThreads = []; + try { + const states = await mapWithConcurrency(pending, RETYPE_CONCURRENCY, async (thread) => { + const state = await channelRepository.threads.setThreadType(thread.threadId, type); + retyped.push(thread); + return state; + }); + const parent = await writeParent(); + return {parent, active: states.filter((state): state is ThreadState => state !== null && !state.archived)}; + } catch (error) { + await mapWithConcurrency(retyped, RETYPE_CONCURRENCY, (thread) => + channelRepository.threads.setThreadType(thread.threadId, thread.type), + ).catch((rollbackError) => { + Logger.error({error: rollbackError}, 'Failed to roll back thread retype after a failed parent conversion'); + }); + throw error; + } +} + +export async function retypedThreadEvents( + channelRepository: IChannelRepositoryAggregate, + parent: Channel, + active: Array, +): Promise> { + if (active.length === 0) return []; + const views = await loadThreadViews(channelRepository, active, [parent]); + return views.map((view) => threadUpdateEvent(view)); +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadParentSettings.ts b/fluxer_api/src/api/channel/services/thread/ThreadParentSettings.ts new file mode 100644 index 000000000..f924f0b1f --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadParentSettings.ts @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createEmojiID, type GuildID} from '@app/api/BrandedTypes'; +import type {IThreadRepository, ThreadParentConfigPatch} from '@app/api/channel/repositories/IThreadRepository'; +import type {ForumTagUdt} from '@app/api/database/types/ThreadTypes'; +import {guildActive, type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate'; +import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; +import type {Channel} from '@app/api/models/Channel'; +import type {ThreadParentConfig} from '@app/api/models/ThreadParentConfig'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import { + ChannelFlags, + ForumLayoutTypes, + ForumTagSettings, + MAX_FORUM_TAGS_PER_CHANNEL, + settableChannelFlags, + THREAD_ONLY_CHANNEL_TYPES, + THREAD_PARENT_CHANNEL_TYPES, +} from '@fluxer/constants/src/ThreadConstants'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; +import {ForumTagNamesMustBeUniqueError} from '@fluxer/errors/src/domains/channel/ForumTagNamesMustBeUniqueError'; +import {HideMediaDownloadOptionMediaOnlyError} from '@fluxer/errors/src/domains/channel/HideMediaDownloadOptionMediaOnlyError'; +import {MaxForumTagsError} from '@fluxer/errors/src/domains/channel/MaxForumTagsError'; +import {NoTagsAvailableToNonModeratorsError} from '@fluxer/errors/src/domains/channel/NoTagsAvailableToNonModeratorsError'; +import {UnknownForumTagError} from '@fluxer/errors/src/domains/channel/UnknownForumTagError'; +import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; +import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type { + DefaultReactionEmojiRequest, + ForumTagUpdateRequest, +} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas'; +import {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators'; + +export interface ThreadParentSettingsInput { + default_auto_archive_duration?: number | null; + default_thread_rate_limit_per_user?: number | null; + available_tags?: Array; + default_reaction_emoji?: DefaultReactionEmojiRequest | null; + default_sort_order?: number | null; + default_forum_layout?: number | null; + default_tag_setting?: string | null; + flags?: number; +} + +const TEXT_PARENT_KEYS = ['default_auto_archive_duration', 'default_thread_rate_limit_per_user'] as const; +const THREAD_ONLY_KEYS = [ + ...TEXT_PARENT_KEYS, + 'available_tags', + 'default_reaction_emoji', + 'default_sort_order', + 'default_forum_layout', + 'default_tag_setting', + 'flags', +] as const; + +export function pickThreadParentSettings(channelType: number, data: object): ThreadParentSettingsInput | null { + if (!THREAD_PARENT_CHANNEL_TYPES.has(channelType)) return null; + const keys = THREAD_ONLY_CHANNEL_TYPES.has(channelType) ? THREAD_ONLY_KEYS : TEXT_PARENT_KEYS; + const source = data as Record; + const picked: Record = {}; + for (const key of keys) { + if (source[key] !== undefined) picked[key] = source[key]; + } + if (channelType !== ChannelTypes.GUILD_FORUM) delete picked.default_forum_layout; + return Object.keys(picked).length > 0 ? (picked as ThreadParentSettingsInput) : null; +} + +export function assertSettableParentFlags(channelType: number, flags: number): void { + const settable = settableChannelFlags(channelType); + if ((flags & ~settable) === 0) return; + if ((flags & ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS) !== 0 && channelType !== ChannelTypes.GUILD_MEDIA) { + throw new HideMediaDownloadOptionMediaOnlyError(); + } + throw InputValidationError.fromCode('flags', ValidationErrorCodes.INVALID_FORMAT); +} + +function assertSingleEmoji( + field: string, + emoji: {emoji_id?: bigint | null; emoji_name?: string | null} | null | undefined, +): void { + if (emoji?.emoji_id != null && emoji.emoji_name != null) { + throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_FORMAT); + } + if (emoji?.emoji_name != null && !isValidSingleUnicodeEmoji(emoji.emoji_name)) { + throw InputValidationError.fromCode(`${field}.emoji_name`, ValidationErrorCodes.NOT_A_VALID_UNICODE_EMOJI); + } +} + +async function assertGuildEmoji( + guildRepository: IGuildRepositoryAggregate, + guildId: GuildID, + emojiId: bigint | null | undefined, +): Promise { + if (emojiId == null) return; + const emoji = await guildRepository.getEmoji(createEmojiID(emojiId), guildId); + if (!emoji) throw new UnknownGuildEmojiError(); +} + +async function buildTags(params: { + input: Array; + current: ReadonlyArray; + guildId: GuildID; + guildRepository: IGuildRepositoryAggregate; + generateId: () => Promise; +}): Promise> { + if (params.input.length > MAX_FORUM_TAGS_PER_CHANNEL) throw new MaxForumTagsError(MAX_FORUM_TAGS_PER_CHANNEL); + const existing = new Set(params.current.map((tag) => tag.id)); + const storedEmoji = new Map(params.current.map((tag) => [tag.id, tag.emoji_id ?? null])); + const names = new Set(); + const tags: Array = []; + for (const [index, tag] of params.input.entries()) { + if (names.has(tag.name)) throw new ForumTagNamesMustBeUniqueError(); + names.add(tag.name); + assertSingleEmoji(`available_tags.${index}`, tag); + if (tag.id === undefined || storedEmoji.get(tag.id) !== (tag.emoji_id ?? null)) { + await assertGuildEmoji(params.guildRepository, params.guildId, tag.emoji_id); + } + if (tag.id !== undefined) { + if (!existing.delete(tag.id)) throw new UnknownForumTagError(); + } + tags.push({ + id: tag.id ?? (await params.generateId()), + name: tag.name, + moderated: tag.moderated ?? false, + emoji_id: tag.emoji_id ?? null, + emoji_name: tag.emoji_name ?? null, + }); + } + return tags; +} + +export async function buildThreadParentPatch(params: { + channelType: number; + guildId: GuildID; + input: ThreadParentSettingsInput; + current: ThreadParentConfig | null; + guildRepository: IGuildRepositoryAggregate; + generateId: () => Promise; +}): Promise { + const {input, current} = params; + const patch: ThreadParentConfigPatch = {}; + if (input.default_auto_archive_duration !== undefined) { + patch.default_auto_archive_duration = input.default_auto_archive_duration; + } + if (input.default_thread_rate_limit_per_user !== undefined) { + patch.default_thread_rate_limit_per_user = input.default_thread_rate_limit_per_user; + } + if (input.flags !== undefined) { + assertSettableParentFlags(params.channelType, input.flags); + patch.flags = input.flags; + } + if (input.available_tags !== undefined) { + patch.available_tags = await buildTags({ + input: input.available_tags, + current: current?.availableTags.map((tag) => tag.toUdt()) ?? [], + guildId: params.guildId, + guildRepository: params.guildRepository, + generateId: params.generateId, + }); + } + if (input.default_reaction_emoji !== undefined) { + const emoji = input.default_reaction_emoji; + assertSingleEmoji('default_reaction_emoji', emoji); + await assertGuildEmoji(params.guildRepository, params.guildId, emoji?.emoji_id); + patch.default_reaction_emoji_id = emoji?.emoji_id ?? null; + patch.default_reaction_emoji_name = emoji?.emoji_name ?? null; + } + if (input.default_sort_order !== undefined) patch.default_sort_order = input.default_sort_order; + if (input.default_forum_layout !== undefined) patch.default_forum_layout = input.default_forum_layout; + if (input.default_tag_setting !== undefined) patch.default_tag_setting = input.default_tag_setting; + const flags = patch.flags ?? current?.flags ?? 0; + const tags = patch.available_tags ?? current?.availableTags.map((tag) => tag.toUdt()) ?? []; + if ((flags & ChannelFlags.REQUIRE_TAG) !== 0 && !tags.some((tag) => !tag.moderated)) { + throw new NoTagsAvailableToNonModeratorsError(); + } + return patch; +} + +export function mapThreadParentFields( + channelType: number, + config: ThreadParentConfig | null, +): Partial { + if (!THREAD_ONLY_CHANNEL_TYPES.has(channelType)) { + const fields: Partial = {}; + if (config?.defaultAutoArchiveDuration != null) { + fields.default_auto_archive_duration = config.defaultAutoArchiveDuration; + } + if (config?.defaultThreadRateLimitPerUser != null) { + fields.default_thread_rate_limit_per_user = config.defaultThreadRateLimitPerUser; + } + return fields; + } + const fields: Partial = { + flags: config?.flags ?? 0, + default_auto_archive_duration: config?.defaultAutoArchiveDuration ?? null, + default_thread_rate_limit_per_user: config?.defaultThreadRateLimitPerUser ?? 0, + available_tags: (config?.availableTags ?? []).map((tag) => ({ + id: tag.id.toString(), + name: tag.name, + moderated: tag.moderated, + emoji_id: tag.emojiId?.toString() ?? null, + emoji_name: tag.emojiName, + })), + default_reaction_emoji: + config && (config.defaultReactionEmojiId !== null || config.defaultReactionEmojiName !== null) + ? { + emoji_id: config.defaultReactionEmojiId?.toString() ?? null, + emoji_name: config.defaultReactionEmojiName, + } + : null, + default_sort_order: config?.defaultSortOrder ?? null, + default_tag_setting: + config?.defaultTagSetting === ForumTagSettings.MATCH_ALL + ? ForumTagSettings.MATCH_ALL + : ForumTagSettings.MATCH_SOME, + }; + if (channelType === ChannelTypes.GUILD_FORUM) { + fields.default_forum_layout = config?.defaultForumLayout ?? ForumLayoutTypes.DEFAULT; + } + return fields; +} + +export function serializeThreadParentForAudit( + channelType: number, + config: ThreadParentConfig | null, +): Record { + if (!config) return {}; + const fields = mapThreadParentFields(channelType, config); + return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== null && value !== undefined)); +} + +function isThreadParentInGuild(channel: Channel): channel is Channel & {guildId: GuildID} { + return channel.guildId !== null && THREAD_PARENT_CHANNEL_TYPES.has(channel.type); +} + +export async function loadThreadParentConfig( + threads: IThreadRepository, + channel: Channel, +): Promise { + if (!isThreadParentInGuild(channel) || !guildActive(channel.guildId)) return null; + return threads.getParentConfig(channel.guildId, channel.id); +} + +export async function withThreadParentFields( + threads: IThreadRepository, + channel: Channel, + response: ChannelResponse, + viewer?: ThreadViewer, +): Promise { + if (!isThreadParentInGuild(channel) || !guildActive(channel.guildId)) return response; + if (viewer && !viewerActive(viewer, channel.guildId)) return response; + const config = await threads.getParentConfig(channel.guildId, channel.id); + return {...response, ...mapThreadParentFields(channel.type, config)}; +} + +export async function withThreadParentFieldsMany( + threads: IThreadRepository, + guildId: GuildID, + channels: ReadonlyArray, + responses: Array, + viewer?: ThreadViewer, +): Promise> { + if (!guildActive(guildId) || (viewer && !viewerActive(viewer, guildId))) return responses; + if (!channels.some((channel) => THREAD_PARENT_CHANNEL_TYPES.has(channel.type))) return responses; + const configs = new Map( + (await threads.listParentConfigs(guildId)).map((config) => [config.channelId.toString(), config]), + ); + const types = new Map(channels.map((channel) => [channel.id.toString(), channel.type])); + return responses.map((response) => { + const type = types.get(response.id); + if (type === undefined || !THREAD_PARENT_CHANNEL_TYPES.has(type)) return response; + return {...response, ...mapThreadParentFields(type, configs.get(response.id) ?? null)}; + }); +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadRpcData.ts b/fluxer_api/src/api/channel/services/thread/ThreadRpcData.ts new file mode 100644 index 000000000..3a184fe4e --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadRpcData.ts @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import {mapThreadMemberToRpcResponse, mapThreadToRpcResponse} from '@app/api/channel/services/thread/ThreadMappers'; +import {mapThreadParentFields} from '@app/api/channel/services/thread/ThreadParentSettings'; +import {loadThreadViews} from '@app/api/channel/services/thread/ThreadViews'; +import {getCompiledChannelThreadsConfig, guildActive, isTainted} from '@app/api/experiment/ChannelThreadsGate'; +import type {Channel} from '@app/api/models/Channel'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; +import type {ThreadParentConfig} from '@app/api/models/ThreadParentConfig'; +import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils'; +import { + FORUM_UNREAD_COUNT_CAP, + MAX_THREAD_MEMBERS, + THREAD_ONLY_CHANNEL_TYPES, +} from '@fluxer/constants/src/ThreadConstants'; +import type { + ThreadMemberRpcResponse, + ThreadParentSettingsRpcResponse, +} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; + +const MEMBER_PAGE_SIZE = 1000; +const MEMBER_LOAD_CONCURRENCY = 16; + +export type ThreadGateRpcData = + | {thread_gate: {active: boolean; config_version: number}; thread_tainted: boolean} + | {thread_tainted: true}; + +export interface ThreadCollectionRpcData { + threads: Array>; + thread_members?: Array; + thread_parent_settings: Array; +} + +export async function resolveThreadGateRpcData(guildId: GuildID): Promise { + if (!guildActive(guildId)) return (await isTainted(guildId)) ? {thread_tainted: true} : null; + return { + thread_gate: {active: true, config_version: getCompiledChannelThreadsConfig().config.config_version}, + thread_tainted: await isTainted(guildId), + }; +} + +function mapParentSettingsToRpc( + parent: Channel, + config: ThreadParentConfig | null, +): ThreadParentSettingsRpcResponse | null { + if (config === null && !THREAD_ONLY_CHANNEL_TYPES.has(parent.type)) return null; + return {channel_id: parent.id.toString(), ...mapThreadParentFields(parent.type, config)}; +} + +async function listAllMembers( + repository: IChannelRepositoryAggregate, + threadId: ChannelID, +): Promise> { + const members: Array = []; + let after: UserID | undefined; + while (members.length < MAX_THREAD_MEMBERS * 2) { + const page = await repository.threads.listMembers(threadId, {after, limit: MEMBER_PAGE_SIZE}); + members.push(...page); + if (page.length < MEMBER_PAGE_SIZE) break; + after = page[page.length - 1]!.userId; + } + return members; +} + +export async function loadThreadCollectionRpcData( + repository: IChannelRepositoryAggregate, + guildId: GuildID, + parents: ReadonlyArray, + {members: includeMembers}: {members: boolean}, +): Promise { + const [states, parentConfigs] = await Promise.all([ + repository.threads.listActiveThreads(guildId), + repository.threads.listParentConfigs(guildId), + ]); + const views = await loadThreadViews(repository, states, parents); + const members = includeMembers + ? await mapWithConcurrency(views, MEMBER_LOAD_CONCURRENCY, (view) => + listAllMembers(repository, view.state.threadId), + ) + : null; + const configs = new Map(parentConfigs.map((config) => [config.channelId, config])); + return { + threads: views.map(mapThreadToRpcResponse), + ...(members ? {thread_members: members.flat().map(mapThreadMemberToRpcResponse)} : {}), + thread_parent_settings: parents.flatMap((parent) => { + const settings = mapParentSettingsToRpc(parent, configs.get(parent.id) ?? null); + return settings ? [settings] : []; + }), + }; +} + +export async function loadActiveThreadMemberships( + repository: IChannelRepositoryAggregate, + guildId: GuildID, + userId: UserID, +): Promise> { + const joined = await repository.threads.listJoinedThreadIds(userId, guildId); + if (joined.length === 0) return []; + const states = await repository.threads.getStates(joined); + const active = states.filter((state) => !state.archived && state.guildId === guildId); + const members = await Promise.all(active.map((state) => repository.threads.getMember(state.threadId, userId))); + return members.flatMap((member) => (member ? [mapThreadMemberToRpcResponse(member)] : [])); +} + +export async function listThreadMembersPage( + repository: IChannelRepositoryAggregate, + params: {guildId: GuildID; threadIds: Array; limit: number; after?: UserID}, +): Promise<{ + members: Array; + has_more: boolean; + next_thread_id: string | null; + next_after_user_id: string | null; +}> { + const states = await repository.threads.getStates(params.threadIds); + const inGuild = new Set(states.filter((state) => state.guildId === params.guildId).map((state) => state.threadId)); + const members: Array = []; + let after = params.after; + for (const threadId of params.threadIds) { + const cursor = after; + after = undefined; + if (!inGuild.has(threadId)) continue; + const remaining = params.limit - members.length; + const page = await repository.threads.listMembers(threadId, {after: cursor, limit: remaining + 1}); + if (page.length > remaining) { + members.push(...page.slice(0, remaining)); + return { + members: members.map(mapThreadMemberToRpcResponse), + has_more: true, + next_thread_id: threadId.toString(), + next_after_user_id: remaining > 0 ? page[remaining - 1]!.userId.toString() : null, + }; + } + members.push(...page); + } + return { + members: members.map(mapThreadMemberToRpcResponse), + has_more: false, + next_thread_id: null, + next_after_user_id: null, + }; +} + +export interface ForumUnreadRpcEntry { + thread_id: string; + count?: number; + missing?: true; +} + +export async function loadForumUnreads( + repository: IChannelRepositoryAggregate, + params: { + guildId: GuildID; + channelId: ChannelID; + threads: Array<{threadId: ChannelID; ackMessageId?: MessageID}>; + }, +): Promise> { + if (!guildActive(params.guildId)) return []; + const states = await repository.threads.getStates(params.threads.map((thread) => thread.threadId)); + const inForum = new Set( + states + .filter((state) => state.guildId === params.guildId && state.parentId === params.channelId) + .map((state) => state.threadId), + ); + return Promise.all( + params.threads + .filter((thread) => inForum.has(thread.threadId)) + .map(async ({threadId, ackMessageId}): Promise => { + if (ackMessageId === undefined) return {thread_id: threadId.toString(), missing: true}; + const unread = await repository.messages.listMessages( + threadId, + undefined, + FORUM_UNREAD_COUNT_CAP, + ackMessageId, + ); + return {thread_id: threadId.toString(), count: unread.length}; + }), + ); +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadService.ts b/fluxer_api/src/api/channel/services/thread/ThreadService.ts new file mode 100644 index 000000000..56ce820be --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadService.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {ThreadCreationService} from '@app/api/channel/services/thread/ThreadCreationService'; +import {ThreadDeletionService} from '@app/api/channel/services/thread/ThreadDeletionService'; +import {ThreadForumService} from '@app/api/channel/services/thread/ThreadForumService'; +import {ThreadListService} from '@app/api/channel/services/thread/ThreadListService'; +import {ThreadMemberService} from '@app/api/channel/services/thread/ThreadMemberService'; +import {ThreadMemberSettingsService} from '@app/api/channel/services/thread/ThreadMemberSettingsService'; +import {ThreadServiceContext, type ThreadServiceDeps} from '@app/api/channel/services/thread/ThreadServiceContext'; + +export class ThreadService { + readonly creation: ThreadCreationService; + readonly members: ThreadMemberService; + readonly memberSettings: ThreadMemberSettingsService; + readonly lists: ThreadListService; + readonly deletion: ThreadDeletionService; + readonly forum: ThreadForumService; + + constructor(deps: ThreadServiceDeps) { + const context = new ThreadServiceContext(deps); + this.creation = new ThreadCreationService(context); + this.members = new ThreadMemberService(context); + this.memberSettings = new ThreadMemberSettingsService(context); + this.lists = new ThreadListService(context); + this.deletion = new ThreadDeletionService(context); + this.forum = new ThreadForumService(context); + } +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadServiceContext.ts b/fluxer_api/src/api/channel/services/thread/ThreadServiceContext.ts new file mode 100644 index 000000000..20afca8ce --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadServiceContext.ts @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; +import type {MessageRequest} from '@app/api/channel/MessageTypes'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel'; +import type {BaseChannelAuthService} from '@app/api/channel/services/BaseChannelAuthService'; +import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService'; +import type {ThreadMessageResponses} from '@app/api/channel/services/message/ThreadMessageResponses'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; +import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; +import type {GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes'; +import {isGuildMemberTimedOut} from '@app/api/guild/GuildModel'; +import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; +import type {KVThreadAutoArchiveQueueService} from '@app/api/infrastructure/KVThreadAutoArchiveQueueService'; +import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; +import {Logger} from '@app/api/Logger'; +import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import type {Channel} from '@app/api/models/Channel'; +import type {User} from '@app/api/models/User'; +import type {ReadStateService} from '@app/api/read_state/ReadStateService'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import type {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; +import type {ThreadActor} from '@fluxer/constants/src/ThreadPermissionUtils'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import type {ICacheService} from '@pkgs/cache/src/ICacheService'; +import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService'; + +export interface ThreadServiceDeps { + channelRepository: IChannelRepositoryAggregate; + guildRepository: IGuildRepositoryAggregate; + userRepository: IUserRepository; + channelAuth: BaseChannelAuthService; + gatewayService: IGatewayService; + snowflakeService: ISnowflakeService; + rateLimitService: IRateLimitService; + cacheService: ICacheService; + guildAuditLogService: GuildAuditLogService; + userCacheService: UserCacheService; + archiveQueue: KVThreadAutoArchiveQueueService; + messagePersistence: MessagePersistenceService; + threadMessageResponses: ThreadMessageResponses; + purgeChannelAttachments: (channel: Channel) => Promise; + readStateService: ReadStateService; + sendForumStarter: (params: ForumStarterParams) => Promise; + validateForumStarter: (params: Pick) => Promise; +} + +export interface ForumStarterParams { + user: User; + viewer: ThreadViewer; + parentAuth: AuthenticatedChannel; + threadId: ChannelID; + data: MessageRequest; + requestCache: RequestCache; +} + +export class ThreadServiceContext { + readonly channelRepository: IChannelRepositoryAggregate; + readonly guildRepository: IGuildRepositoryAggregate; + readonly userRepository: IUserRepository; + readonly channelAuth: BaseChannelAuthService; + readonly gatewayService: IGatewayService; + readonly snowflakeService: ISnowflakeService; + readonly rateLimitService: IRateLimitService; + readonly cacheService: ICacheService; + readonly guildAuditLogService: GuildAuditLogService; + readonly userCacheService: UserCacheService; + readonly archiveQueue: KVThreadAutoArchiveQueueService; + readonly messagePersistence: MessagePersistenceService; + readonly threadMessageResponses: ThreadMessageResponses; + readonly purgeChannelAttachments: (channel: Channel) => Promise; + readonly readStateService: ReadStateService; + readonly sendForumStarter: (params: ForumStarterParams) => Promise; + readonly validateForumStarter: (params: Pick) => Promise; + + constructor(deps: ThreadServiceDeps) { + this.channelRepository = deps.channelRepository; + this.guildRepository = deps.guildRepository; + this.userRepository = deps.userRepository; + this.channelAuth = deps.channelAuth; + this.gatewayService = deps.gatewayService; + this.snowflakeService = deps.snowflakeService; + this.rateLimitService = deps.rateLimitService; + this.cacheService = deps.cacheService; + this.guildAuditLogService = deps.guildAuditLogService; + this.userCacheService = deps.userCacheService; + this.archiveQueue = deps.archiveQueue; + this.messagePersistence = deps.messagePersistence; + this.threadMessageResponses = deps.threadMessageResponses; + this.purgeChannelAttachments = deps.purgeChannelAttachments; + this.readStateService = deps.readStateService; + this.sendForumStarter = deps.sendForumStarter; + this.validateForumStarter = deps.validateForumStarter; + } + + async parentActor(auth: AuthenticatedChannel, userId: UserID): Promise { + const permissions = await this.gatewayService.getUserPermissions({ + guildId: auth.channel.guildId!, + userId, + channelId: auth.channel.id, + }); + return { + permissions, + isOwner: auth.guild?.owner_id === userId.toString(), + timedOut: isGuildMemberTimedOut(auth.member), + }; + } + + async recordAudit(params: { + guildId: GuildID; + userId: UserID; + action: AuditLogActionType; + targetId: ChannelID | UserID; + auditLogReason: string | null; + metadata?: Record; + changes?: GuildAuditLogChange | null; + }): Promise { + try { + const builder = this.guildAuditLogService + .createBuilder(params.guildId, params.userId) + .withAction(params.action, params.targetId.toString()) + .withReason(params.auditLogReason); + if (params.metadata) builder.withMetadata(params.metadata); + if (params.changes) builder.withChanges(params.changes); + await builder.commit(); + } catch (error) { + Logger.error( + {error, guildId: params.guildId.toString(), action: params.action}, + 'Failed to record thread audit log', + ); + } + } +} diff --git a/fluxer_api/src/api/channel/services/thread/ThreadViews.ts b/fluxer_api/src/api/channel/services/thread/ThreadViews.ts new file mode 100644 index 000000000..595e61453 --- /dev/null +++ b/fluxer_api/src/api/channel/services/thread/ThreadViews.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID} from '@app/api/BrandedTypes'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import type {ThreadView} from '@app/api/channel/services/thread/ThreadMappers'; +import type {Channel} from '@app/api/models/Channel'; +import {ThreadState} from '@app/api/models/ThreadState'; +import {ThreadStats} from '@app/api/models/ThreadStats'; +import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; + +async function liveTagIds( + repository: IChannelRepositoryAggregate, + guildId: GuildID, + parentId: ChannelID, +): Promise> { + const config = await repository.threads.getParentConfig(guildId, parentId); + return new Set(config?.availableTags.map((tag) => tag.id)); +} + +function withLiveTags(state: ThreadState, tagIds: ReadonlySet | undefined): ThreadState { + if (!tagIds || state.appliedTags.every((tag) => tagIds.has(tag))) return state; + return new ThreadState({...state.toRow(), applied_tags: state.appliedTags.filter((tag) => tagIds.has(tag))}); +} + +export async function loadThreadViews( + repository: IChannelRepositoryAggregate, + states: Array, + knownParents: ReadonlyArray = [], +): Promise> { + if (states.length === 0) return []; + const threadIds = states.map((state) => state.threadId); + const parentTypes = new Map(knownParents.map((parent) => [parent.id, parent.type])); + const missingParents = [...new Set(states.map((state) => state.parentId))].filter((id) => !parentTypes.has(id)); + const [channels, stats, parents] = await Promise.all([ + repository.channelData.listChannels(threadIds), + repository.threads.getStatsMany(threadIds), + missingParents.length > 0 ? repository.channelData.listChannels(missingParents) : Promise.resolve([]), + ]); + for (const parent of parents) parentTypes.set(parent.id, parent.type); + const taggedParents = [ + ...new Map( + states + .filter( + (state) => + state.appliedTags.length > 0 && THREAD_ONLY_CHANNEL_TYPES.has(parentTypes.get(state.parentId) ?? -1), + ) + .map((state) => [state.parentId, state.guildId] as const), + ), + ]; + const tagsByParent = new Map( + await Promise.all( + taggedParents.map( + async ([parentId, guildId]) => [parentId, await liveTagIds(repository, guildId, parentId)] as const, + ), + ), + ); + const channelById = new Map(channels.map((channel) => [channel.id, channel])); + return states.flatMap((state) => { + const channel = channelById.get(state.threadId); + if (!channel) return []; + return [ + { + channel, + state: withLiveTags(state, tagsByParent.get(state.parentId)), + stats: stats.get(state.threadId) ?? ThreadStats.empty(state.threadId), + parentType: parentTypes.get(state.parentId) ?? null, + }, + ]; + }); +} + +export async function loadThreadView( + repository: IChannelRepositoryAggregate, + channel: Channel, + state: ThreadState, + parent: Channel, +): Promise { + const [stats, tagIds] = await Promise.all([ + repository.threads.getStats(channel.id), + state.appliedTags.length > 0 && parent.isThreadOnly() + ? liveTagIds(repository, state.guildId, parent.id) + : Promise.resolve(undefined), + ]); + return {channel, state: withLiveTags(state, tagIds), stats, parentType: parent.type}; +} diff --git a/fluxer_api/src/api/channel/tests/AnnouncementThreadRaces.test.ts b/fluxer_api/src/api/channel/tests/AnnouncementThreadRaces.test.ts new file mode 100644 index 000000000..9abdef5b6 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/AnnouncementThreadRaces.test.ts @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID, createMessageID} from '@app/api/BrandedTypes'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {ChannelDataRepository} from '@app/api/channel/repositories/ChannelDataRepository'; +import {MessageInteractionRepository} from '@app/api/channel/repositories/MessageInteractionRepository'; +import {MessageRepository} from '@app/api/channel/repositories/MessageRepository'; +import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository'; +import {ChannelOperationsService} from '@app/api/channel/services/channel_data/ChannelOperationsService'; +import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {BatchBuilder} from '@app/api/database/CassandraQueryExecution'; +import {sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {ChannelTypes, MessageFlags} from '@fluxer/constants/src/ChannelConstants'; +import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface Setup { + owner: TestAccount; + guildId: string; + textId: string; + announcementId: string; +} + +const repository = new ChannelRepository(); + +describe('announcement thread races', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'news races'); + const text = await createChannel(harness, owner.token, guild.id, 'general'); + const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT); + return {owner, guildId: guild.id, textId: text.id, announcementId: announcement.id}; + } + + async function startThread(token: string, channelId: string): Promise { + return threadsRequest(harness, token) + .post(`/channels/${channelId}/threads`) + .body({name: 'topic', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + } + + it('keeps a thread archived during a retype out of the active index', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.textId); + const threadId = createChannelID(BigInt(thread.id)); + const execute = BatchBuilder.prototype.execute; + let armed = true; + vi.spyOn(BatchBuilder.prototype, 'execute').mockImplementation(async function ( + this: BatchBuilder, + ...args: Parameters + ) { + if (armed) { + armed = false; + await repository.threads.updateState(threadId, () => ({archived: true})); + } + return execute.apply(this, args); + }); + await repository.threads.setThreadType(threadId, ChannelTypes.ANNOUNCEMENT_THREAD); + vi.restoreAllMocks(); + expect((await repository.threads.getState(threadId))?.archived).toBe(true); + expect(await repository.threads.countActiveThreads(createGuildID(BigInt(s.guildId)))).toBe(0); + }); + + it('leaves no rows behind for a thread deleted during a retype', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.textId); + const threadId = createChannelID(BigInt(thread.id)); + const execute = BatchBuilder.prototype.execute; + let armed = true; + vi.spyOn(BatchBuilder.prototype, 'execute').mockImplementation(async function ( + this: BatchBuilder, + ...args: Parameters + ) { + if (armed) { + armed = false; + await repository.threads.purgeThread(threadId); + } + return execute.apply(this, args); + }); + expect(await repository.threads.setThreadType(threadId, ChannelTypes.ANNOUNCEMENT_THREAD)).toBeNull(); + vi.restoreAllMocks(); + expect(await repository.findUnique(threadId)).toBeNull(); + expect(await repository.threads.getState(threadId)).toBeNull(); + expect(await repository.threads.listParentThreads(createChannelID(BigInt(s.textId)))).toEqual([]); + expect(await repository.threads.countActiveThreads(createGuildID(BigInt(s.guildId)))).toBe(0); + }); + + it('does not revert a retyped thread when a rename read the old row', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.textId); + const threadId = createChannelID(BigInt(thread.id)); + const updateState = ThreadRepository.prototype.updateState; + vi.spyOn(ThreadRepository.prototype, 'updateState').mockImplementationOnce(async function ( + this: ThreadRepository, + ...args: Parameters + ) { + const result = await updateState.apply(this, args); + await repository.threads.setThreadType(threadId, ChannelTypes.ANNOUNCEMENT_THREAD); + return result; + }); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${thread.id}`) + .body({name: 'renamed'}) + .expect(200) + .execute(); + vi.restoreAllMocks(); + const stored = await repository.findUnique(threadId); + expect(stored?.name).toBe('renamed'); + expect(stored?.type).toBe(ChannelTypes.ANNOUNCEMENT_THREAD); + expect((await repository.threads.getState(threadId))?.type).toBe(ChannelTypes.ANNOUNCEMENT_THREAD); + }); + + it('does not revert a retyped thread when a pin read the old row', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.textId); + const threadId = createChannelID(BigInt(thread.id)); + const message = await threadsRequest(harness, s.owner.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'pin me'}) + .expect(200) + .execute(); + const addChannelPin = MessageInteractionRepository.prototype.addChannelPin; + vi.spyOn(MessageInteractionRepository.prototype, 'addChannelPin').mockImplementationOnce(async function ( + this: MessageInteractionRepository, + ...args: Parameters + ) { + await addChannelPin.apply(this, args); + await repository.threads.setThreadType(threadId, ChannelTypes.ANNOUNCEMENT_THREAD); + }); + await threadsRequest(harness, s.owner.token).put(`/channels/${thread.id}/pins/${message.id}`).expect(204).execute(); + vi.restoreAllMocks(); + const stored = await repository.findUnique(threadId); + expect(stored?.lastPinTimestamp).not.toBeNull(); + expect(stored?.type).toBe(ChannelTypes.ANNOUNCEMENT_THREAD); + expect((await repository.threads.getState(threadId))?.type).toBe(ChannelTypes.ANNOUNCEMENT_THREAD); + }); + + it('does not revert a converted parent when an overwrite edit read the old row', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.textId); + const operations = ChannelOperationsService.prototype as unknown as {checkOverwritePermission: () => Promise}; + vi.spyOn(operations, 'checkOverwritePermission').mockImplementationOnce(async () => { + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.textId}`) + .body({type: ChannelTypes.GUILD_ANNOUNCEMENT}) + .expect(200) + .execute(); + }); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${s.textId}/permissions/${s.owner.userId}`) + .body({type: 1, allow: '0', deny: '0'}) + .expect(204) + .execute(); + vi.restoreAllMocks(); + const parent = await repository.findUnique(createChannelID(BigInt(s.textId))); + expect(parent?.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT); + expect(parent?.permissionOverwrites?.size ?? 0).toBeGreaterThan(0); + expect((await repository.threads.getState(createChannelID(BigInt(thread.id))))?.type).toBe( + ChannelTypes.ANNOUNCEMENT_THREAD, + ); + }); + + it('restores retyped threads when the parent write fails', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.textId); + const threadId = createChannelID(BigInt(thread.id)); + const upsert = ChannelDataRepository.prototype.upsert; + vi.spyOn(ChannelDataRepository.prototype, 'upsert').mockImplementation(async function ( + this: ChannelDataRepository, + ...args: Parameters + ) { + if ( + args[0].channel_id === createChannelID(BigInt(s.textId)) && + args[0].type === ChannelTypes.GUILD_ANNOUNCEMENT + ) { + throw new Error('write timeout'); + } + return upsert.apply(this, args); + }); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.textId}`) + .body({type: ChannelTypes.GUILD_ANNOUNCEMENT}) + .expect(500) + .execute(); + vi.restoreAllMocks(); + expect((await repository.findUnique(createChannelID(BigInt(s.textId))))?.type).toBe(ChannelTypes.GUILD_TEXT); + expect((await repository.findUnique(threadId))?.type).toBe(ChannelTypes.PUBLIC_THREAD); + expect((await repository.threads.getState(threadId))?.type).toBe(ChannelTypes.PUBLIC_THREAD); + }); + + it('keeps both flags when a publish races a thread start on the same message', async () => { + const s = await setup(); + const message = await sendMessage(harness, s.owner.token, s.announcementId, 'headline'); + const channelId = createChannelID(BigInt(s.announcementId)); + const messageId = createMessageID(BigInt(message.id)); + const getMessage = MessageRepository.prototype.getMessage; + let publish: Promise | null = null; + vi.spyOn(MessageRepository.prototype, 'getMessage').mockImplementation(async function ( + this: MessageRepository, + ...args: Parameters + ) { + if ( + publish === null && + args[1] === messageId && + (await repository.threads.getState(createChannelID(BigInt(message.id)))) !== null + ) { + publish = threadsRequest(harness, s.owner.token) + .post(`/channels/${s.announcementId}/messages/${message.id}/crosspost`) + .expect(200) + .execute(); + await Promise.race([publish, new Promise((resolve) => setTimeout(resolve, 200))]); + } + return getMessage.apply(this, args); + }); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.announcementId}/messages/${message.id}/threads`) + .body({name: 'Discussion'}) + .expect(201) + .execute(); + expect(publish).not.toBeNull(); + await publish; + vi.restoreAllMocks(); + const stored = await repository.messages.getMessage(channelId, messageId); + expect(stored!.flags & ServerMessageFlags.HAS_THREAD).toBe(ServerMessageFlags.HAS_THREAD); + expect(stored!.flags & MessageFlags.CROSSPOSTED).toBe(MessageFlags.CROSSPOSTED); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/AnnouncementThreads.test.ts b/fluxer_api/src/api/channel/tests/AnnouncementThreads.test.ts new file mode 100644 index 000000000..286b6ef56 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/AnnouncementThreads.test.ts @@ -0,0 +1,515 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID, createMessageID} from '@app/api/BrandedTypes'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository'; +import {disableCrosspostWorker} from '@app/api/channel/tests/AnnouncementTestUtils'; +import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + copiesOf, + type FanoutWorld, + followInto, + setupFanoutWorld, +} from '@app/api/channel/tests/CrosspostWorkerTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes, MessageFlags} from '@fluxer/constants/src/ChannelConstants'; +import {ServerMessageFlags, THREAD_MESSAGE_FLAG_MASK} from '@fluxer/constants/src/ThreadConstants'; +import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type { + ArchivedThreadsResponse, + ThreadChannelResponse, +} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface Setup { + owner: TestAccount; + member: TestAccount; + guildId: string; + textId: string; + announcementId: string; +} + +const repository = new ChannelRepository(); + +async function archive(threadId: string): Promise { + await repository.threads.updateState(createChannelID(BigInt(threadId)), () => ({archived: true})); +} + +describe('announcement threads', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'news threads'); + const text = await createChannel(harness, owner.token, guild.id, 'general'); + const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT); + const invite = await createChannelInvite(harness, owner.token, text.id); + await acceptInvite(harness, member.token, invite.code); + await ensureSessionStarted(harness, member.token); + return {owner, member, guildId: guild.id, textId: text.id, announcementId: announcement.id}; + } + + async function startThread( + token: string, + channelId: string, + body: Record = {name: 'topic', type: ChannelTypes.ANNOUNCEMENT_THREAD}, + ): Promise { + return threadsRequest(harness, token) + .post(`/channels/${channelId}/threads`) + .body(body) + .expect(201) + .execute(); + } + + async function storedType(threadId: string): Promise<{state?: number; channel?: number}> { + const id = createChannelID(BigInt(threadId)); + const [state, channel] = await Promise.all([repository.threads.getState(id), repository.findUnique(id)]); + return {state: state?.type, channel: channel?.type}; + } + + describe('creation', () => { + it('starts an announcement thread from a message in an announcement channel', async () => { + const s = await setup(); + const message = await sendMessage(harness, s.owner.token, s.announcementId, 'headline'); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const thread = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.announcementId}/messages/${message.id}/threads`) + .body({name: 'Discussion'}) + .expect(201) + .execute(); + expect(thread).toMatchObject({ + id: message.id, + type: ChannelTypes.ANNOUNCEMENT_THREAD, + parent_id: s.announcementId, + }); + expect(thread.thread_metadata?.invitable).toBeUndefined(); + const events = dispatch.mock.calls.map(([params]) => params); + expect(events.find((params) => params.event === 'THREAD_CREATE')?.data).toMatchObject({ + type: ChannelTypes.ANNOUNCEMENT_THREAD, + }); + const starter = events.find( + (params) => params.event === 'MESSAGE_CREATE' && (params.data as MessageResponse).channel_id === message.id, + )?.data as MessageResponse | undefined; + expect(starter?.referenced_message?.id).toBe(message.id); + const stored = await repository.messages.getMessage( + createChannelID(BigInt(s.announcementId)), + createMessageID(BigInt(message.id)), + ); + expect(stored!.flags & ServerMessageFlags.HAS_THREAD).toBe(ServerMessageFlags.HAS_THREAD); + expect(await storedType(thread.id)).toEqual({ + state: ChannelTypes.ANNOUNCEMENT_THREAD, + channel: ChannelTypes.ANNOUNCEMENT_THREAD, + }); + }); + + it('creates an announcement thread for either public type and refuses private threads', async () => { + const s = await setup(); + for (const type of [ChannelTypes.ANNOUNCEMENT_THREAD, ChannelTypes.PUBLIC_THREAD]) { + const thread = await startThread(s.owner.token, s.announcementId, {name: `t${type}`, type}); + expect(thread.type).toBe(ChannelTypes.ANNOUNCEMENT_THREAD); + } + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.announcementId}/threads`) + .body({name: 'secret', type: ChannelTypes.PRIVATE_THREAD}) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.textId}/threads`) + .body({name: 'wrong', type: ChannelTypes.ANNOUNCEMENT_THREAD}) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + const text = await startThread(s.owner.token, s.textId, {name: 'plain', type: ChannelTypes.PUBLIC_THREAD}); + expect(text.type).toBe(ChannelTypes.PUBLIC_THREAD); + }); + + it('posts a thread created message in the announcement channel', async () => { + const s = await setup(); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const thread = await startThread(s.owner.token, s.announcementId); + const created = dispatch.mock.calls.map(([params]) => params).find((params) => params.event === 'MESSAGE_CREATE') + ?.data as MessageResponse | undefined; + expect(created).toMatchObject({channel_id: s.announcementId, message_reference: {channel_id: thread.id}}); + }); + + it('derives permissions from the announcement channel', async () => { + const s = await setup(); + const thread = await startThread(s.member.token, s.announcementId); + await threadsRequest(harness, s.member.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'allowed'}) + .expect(200) + .execute(); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${s.announcementId}/permissions/${s.member.userId}`) + .body({type: 1, allow: '0', deny: ThreadPermissionFlags.CREATE_PUBLIC_THREADS.toString()}) + .expect(204) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.announcementId}/threads`) + .body({name: 'denied', type: ChannelTypes.ANNOUNCEMENT_THREAD}) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${s.announcementId}/permissions/${s.member.userId}`) + .body({type: 1, allow: '0', deny: ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS.toString()}) + .expect(204) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'blocked'}) + .expect(403) + .execute(); + }); + }); + + describe('lists and members', () => { + it('returns announcement threads from the public archived list and refuses private lists', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.announcementId); + await archive(thread.id); + const page = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.announcementId}/threads/archived/public`) + .execute(); + expect(page.threads.map((entry) => [entry.id, entry.type])).toEqual([ + [thread.id, ChannelTypes.ANNOUNCEMENT_THREAD], + ]); + for (const path of [ + `/channels/${s.announcementId}/threads/archived/private`, + `/channels/${s.announcementId}/users/@me/threads/archived/private`, + ]) { + await threadsRequest(harness, s.owner.token) + .get(path) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + } + }); + + it('lets members join, chat in, rename and archive an announcement thread', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.announcementId); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/@me`) + .expect(204) + .execute(); + const sent = await threadsRequest(harness, s.member.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'reply'}) + .expect(200) + .execute(); + expect(sent.channel_id).toBe(thread.id); + const renamed = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${thread.id}`) + .body({name: 'renamed', archived: true, invitable: false}) + .expect(200) + .execute(); + expect(renamed).toMatchObject({type: ChannelTypes.ANNOUNCEMENT_THREAD, name: 'renamed'}); + expect(renamed.thread_metadata).toMatchObject({archived: true}); + expect(renamed.thread_metadata?.invitable).toBeUndefined(); + }); + + it('fetches an announcement thread by id', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.announcementId); + const channel = await threadsRequest(harness, s.member.token) + .get(`/channels/${thread.id}`) + .execute(); + expect(channel.type).toBe(ChannelTypes.ANNOUNCEMENT_THREAD); + }); + }); + + describe('conversion', () => { + it('turns public threads into announcement threads and back', async () => { + const s = await setup(); + const active = await startThread(s.owner.token, s.textId, {name: 'live', type: ChannelTypes.PUBLIC_THREAD}); + const archived = await startThread(s.owner.token, s.textId, {name: 'old', type: ChannelTypes.PUBLIC_THREAD}); + await archive(archived.id); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuildMany'); + const single = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const converted = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.textId}`) + .body({type: ChannelTypes.GUILD_ANNOUNCEMENT}) + .expect(200) + .execute(); + expect(converted.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT); + for (const thread of [active, archived]) { + expect(await storedType(thread.id)).toEqual({ + state: ChannelTypes.ANNOUNCEMENT_THREAD, + channel: ChannelTypes.ANNOUNCEMENT_THREAD, + }); + } + const updates = [ + ...single.mock.calls.map(([params]) => params), + ...dispatch.mock.calls.flatMap(([params]) => params.events), + ].filter((params) => params.event === 'THREAD_UPDATE'); + expect(updates.map((params) => (params.data as ThreadChannelResponse).id)).toEqual([active.id]); + expect(updates[0]!.data).toMatchObject({type: ChannelTypes.ANNOUNCEMENT_THREAD}); + const page = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.textId}/threads/archived/public`) + .execute(); + expect(page.threads.map((entry) => entry.type)).toEqual([ChannelTypes.ANNOUNCEMENT_THREAD]); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.textId}`) + .body({type: ChannelTypes.GUILD_TEXT}) + .expect(200) + .execute(); + for (const thread of [active, archived]) { + expect(await storedType(thread.id)).toEqual({ + state: ChannelTypes.PUBLIC_THREAD, + channel: ChannelTypes.PUBLIC_THREAD, + }); + } + }); + + it('refuses converting a text channel with private threads to announcement', async () => { + const s = await setup(); + const secret = await startThread(s.owner.token, s.textId, {name: 'secret', type: ChannelTypes.PRIVATE_THREAD}); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.textId}`) + .body({type: ChannelTypes.GUILD_ANNOUNCEMENT}) + .expect(400, APIErrorCodes.CHANNEL_HAS_THREADS) + .execute(); + expect(await storedType(secret.id)).toEqual({ + state: ChannelTypes.PRIVATE_THREAD, + channel: ChannelTypes.PRIVATE_THREAD, + }); + expect((await repository.findUnique(createChannelID(BigInt(s.textId))))?.type).toBe(ChannelTypes.GUILD_TEXT); + }); + + it('rolls back a failed retype and leaves the parent unconverted so a retry converges', async () => { + const s = await setup(); + const threads = []; + for (const name of ['a', 'b', 'c']) { + threads.push(await startThread(s.owner.token, s.textId, {name, type: ChannelTypes.PUBLIC_THREAD})); + } + await archive(threads[2]!.id); + vi.spyOn(ThreadRepository.prototype, 'setThreadType').mockRejectedValueOnce(new ServiceUnavailableError()); + const update = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.textId}`) + .body({type: ChannelTypes.GUILD_ANNOUNCEMENT}) + .expect(503) + .execute(); + expect((await repository.findUnique(createChannelID(BigInt(s.textId))))?.type).toBe(ChannelTypes.GUILD_TEXT); + expect(update.mock.calls.filter(([params]) => params.event === 'CHANNEL_UPDATE')).toEqual([]); + vi.restoreAllMocks(); + for (const thread of threads) { + expect(await storedType(thread.id)).toEqual({ + state: ChannelTypes.PUBLIC_THREAD, + channel: ChannelTypes.PUBLIC_THREAD, + }); + } + const converted = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.textId}`) + .body({type: ChannelTypes.GUILD_ANNOUNCEMENT}) + .expect(200) + .execute(); + expect(converted.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT); + for (const thread of threads) { + expect(await storedType(thread.id)).toEqual({ + state: ChannelTypes.ANNOUNCEMENT_THREAD, + channel: ChannelTypes.ANNOUNCEMENT_THREAD, + }); + } + }); + + it('refuses converting a parent with retained private threads for control callers and after the experiment is off', async () => { + const s = await setup(); + const secret = await startThread(s.owner.token, s.textId, {name: 'secret', type: ChannelTypes.PRIVATE_THREAD}); + await threadsRequest(harness, s.owner.token, {capable: false}) + .patch(`/channels/${s.textId}`) + .body({type: ChannelTypes.GUILD_ANNOUNCEMENT}) + .expect(400, APIErrorCodes.CHANNEL_HAS_THREADS) + .execute(); + await setChannelThreadsConfig({enabled: false}); + await createBuilder(harness, s.owner.token) + .patch(`/channels/${s.textId}`) + .body({type: ChannelTypes.GUILD_ANNOUNCEMENT}) + .expect(400, APIErrorCodes.CHANNEL_HAS_THREADS) + .execute(); + expect((await repository.findUnique(createChannelID(BigInt(s.textId))))?.type).toBe(ChannelTypes.GUILD_TEXT); + expect(await storedType(secret.id)).toEqual({ + state: ChannelTypes.PRIVATE_THREAD, + channel: ChannelTypes.PRIVATE_THREAD, + }); + }); + + it('keeps converting announcement channels with retained threads after the experiment is off', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.announcementId); + await setChannelThreadsConfig({enabled: false}); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await createBuilder(harness, s.owner.token) + .patch(`/channels/${s.announcementId}`) + .body({type: ChannelTypes.GUILD_TEXT}) + .expect(200) + .execute(); + expect(await storedType(thread.id)).toEqual({ + state: ChannelTypes.PUBLIC_THREAD, + channel: ChannelTypes.PUBLIC_THREAD, + }); + expect(dispatch.mock.calls.filter(([params]) => params.event.startsWith('THREAD_'))).toEqual([]); + }); + }); + + describe('announcements', () => { + let world: FanoutWorld; + + afterEach(() => { + disableCrosspostWorker(); + }); + + it('publishes a message that has a thread without leaking thread flags into follower copies', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + world = await setupFanoutWorld(harness); + await followInto(harness, world, world.b.t1.id); + const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, 'breaking'); + const thread = await threadsRequest(harness, world.a.owner.token) + .post(`/channels/${world.a.ann.id}/messages/${message.id}/threads`) + .body({name: 'comments'}) + .expect(201) + .execute(); + expect(thread.type).toBe(ChannelTypes.ANNOUNCEMENT_THREAD); + const published = await threadsRequest(harness, world.a.owner.token) + .post(`/channels/${world.a.ann.id}/messages/${message.id}/crosspost`) + .expect(200) + .execute(); + await world.worker.drain(); + expect(published.flags & MessageFlags.CROSSPOSTED).toBe(MessageFlags.CROSSPOSTED); + expect(published.thread?.id).toBe(thread.id); + const copies = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id); + expect(copies).toHaveLength(1); + expect(copies[0]!.flags & ServerMessageFlags.HAS_THREAD).toBe(0); + expect(copies[0]!.thread).toBeUndefined(); + }); + + it('never publishes or delivers messages sent inside an announcement thread', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + world = await setupFanoutWorld(harness); + await followInto(harness, world, world.b.t1.id); + const thread = await startThread(world.a.owner.token, world.a.ann.id); + const inner = await threadsRequest(harness, world.a.owner.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'thread chatter'}) + .expect(200) + .execute(); + await threadsRequest(harness, world.a.owner.token) + .post(`/channels/${thread.id}/messages/${inner.id}/crosspost`) + .expect(400, APIErrorCodes.ANNOUNCEMENT_CHANNEL_REQUIRED) + .execute(); + await world.worker.drain(); + expect(world.worker.byTask('crosspostMessage')).toEqual([]); + expect(await copiesOf(harness, world.b.owner.token, world.b.t1.id, inner.id)).toEqual([]); + }); + }); + + describe('control arm', () => { + it('keeps announcement channels unchanged for clients without the capability', async () => { + const s = await setup(); + const message = await sendMessage(harness, s.owner.token, s.announcementId, 'headline'); + await threadsRequest(harness, s.owner.token, {capable: false}) + .post(`/channels/${s.announcementId}/messages/${message.id}/threads`) + .body({name: 'nope'}) + .expect(404) + .execute(); + const updated = await threadsRequest>(harness, s.owner.token, {capable: false}) + .patch(`/channels/${s.announcementId}`) + .body({topic: 'news', default_auto_archive_duration: 60}) + .expect(200) + .execute(); + expect(updated.topic).toBe('news'); + expect('default_auto_archive_duration' in updated).toBe(false); + const config = await repository.threads.getParentConfig( + createGuildID(BigInt(s.guildId)), + createChannelID(BigInt(s.announcementId)), + ); + expect(config?.defaultAutoArchiveDuration ?? null).toBeNull(); + }); + + it('masks thread flags in the publish response for clients without the capability', async () => { + const s = await setup(); + const message = await sendMessage(harness, s.owner.token, s.announcementId, 'headline'); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.announcementId}/messages/${message.id}/threads`) + .body({name: 'Discussion'}) + .expect(201) + .execute(); + const published = await threadsRequest(harness, s.owner.token, {capable: false}) + .post(`/channels/${s.announcementId}/messages/${message.id}/crosspost`) + .expect(200) + .execute(); + expect(published.flags & MessageFlags.CROSSPOSTED).toBe(MessageFlags.CROSSPOSTED); + expect(published.flags & THREAD_MESSAGE_FLAG_MASK).toBe(0); + expect('thread' in published).toBe(false); + }); + + it('stores thread defaults on announcement channels for viewers', async () => { + const s = await setup(); + const updated = await threadsRequest>(harness, s.owner.token) + .patch(`/channels/${s.announcementId}`) + .body({default_auto_archive_duration: 60, default_thread_rate_limit_per_user: 5}) + .expect(200) + .execute(); + expect(updated).toMatchObject({default_auto_archive_duration: 60, default_thread_rate_limit_per_user: 5}); + const thread = await startThread(s.owner.token, s.announcementId); + expect(thread.thread_metadata?.auto_archive_duration).toBe(4320); + expect(thread.rate_limit_per_user).toBe(5); + }); + + it('converts announcement channels in a guild that never had threads exactly as before', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'plain'); + const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const listParentThreads = vi.spyOn(ThreadRepository.prototype, 'listParentThreads'); + const converted = await createBuilder(harness, owner.token) + .patch(`/channels/${announcement.id}`) + .body({type: ChannelTypes.GUILD_TEXT}) + .expect(200) + .execute(); + expect(converted.type).toBe(ChannelTypes.GUILD_TEXT); + expect(listParentThreads).not.toHaveBeenCalled(); + expect(dispatch.mock.calls.filter(([params]) => params.event.startsWith('THREAD_'))).toEqual([]); + const back = await createBuilder(harness, owner.token) + .patch(`/channels/${announcement.id}`) + .body({type: ChannelTypes.GUILD_ANNOUNCEMENT}) + .expect(200) + .execute(); + expect(back.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT); + expect(listParentThreads).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/ChannelFollowLock.test.ts b/fluxer_api/src/api/channel/tests/ChannelFollowLock.test.ts new file mode 100644 index 000000000..63a23b46e --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ChannelFollowLock.test.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createChannelID} from '@app/api/BrandedTypes'; +import {withChannelFollowLock} from '@app/api/channel/services/ChannelFollowers'; +import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider'; +import {describe, expect, it} from 'vitest'; + +describe('withChannelFollowLock', () => { + it('keeps a long conversion lock alive past its ttl', async () => { + const cache = new InMemoryProvider(); + const channelId = createChannelID(1n); + const holder = withChannelFollowLock( + cache, + channelId, + () => new Promise((resolve) => setTimeout(resolve, 1500)), + 0.6, + ); + await new Promise((resolve) => setTimeout(resolve, 1200)); + expect(await cache.acquireLock(`channel-follow:${channelId}`, 1)).toBeNull(); + await holder; + expect(await cache.acquireLock(`channel-follow:${channelId}`, 1)).not.toBeNull(); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/CrosspostPropagation.test.ts b/fluxer_api/src/api/channel/tests/CrosspostPropagation.test.ts index fb260ed5d..bf8583aca 100644 --- a/fluxer_api/src/api/channel/tests/CrosspostPropagation.test.ts +++ b/fluxer_api/src/api/channel/tests/CrosspostPropagation.test.ts @@ -32,6 +32,12 @@ import { workerHelpers, writeRow, } from '@app/api/channel/tests/CrosspostWorkerTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; import type {EmbedService} from '@app/api/infrastructure/EmbedService'; import type {IAssetDeletionQueue} from '@app/api/infrastructure/IAssetDeletionQueue'; import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository'; @@ -58,6 +64,7 @@ import { MessageReferenceTypes, } from '@fluxer/constants/src/ChannelConstants'; import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; +import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants'; import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest'; @@ -86,6 +93,7 @@ describe('Crosspost propagation', () => { afterEach(() => { disableCrosspostWorker(); + resetChannelThreadsConfig(); vi.restoreAllMocks(); }); @@ -383,6 +391,27 @@ describe('Crosspost propagation', () => { expect(await sourceIndex(world.a.ann.id)).not.toContain(source.id); }); + test('deleting the source keeps the thread a follower started from its copy', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + await followInto(harness, world, world.b.t1.id); + const source = await postAndPublish(harness, world, {content: 'thread me'}); + const copy = await onlyCopy(world.b.t1.id, source.id); + await threadsRequest(harness, world.b.owner.token) + .post(`/channels/${world.b.t1.id}/messages/${copy.id}/threads`) + .body({name: 'discussion'}) + .expect(201) + .execute(); + await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, source.id); + await world.worker.drain(); + const row = (await readRow(world.b.t1.id, copy.id))!; + expect(row.flags).toBe(DELETED_COPY_FLAGS | ServerMessageFlags.HAS_THREAD); + expect(row.content).toBe(CROSSPOST_SOURCE_DELETED_CONTENT); + const rendered = await threadsRequest(harness, world.b.owner.token) + .get(`/channels/${world.b.t1.id}/messages/${copy.id}`) + .execute(); + expect(rendered.thread?.id).toBe(copy.id); + }); + describe('other delete paths mark copies too', () => { test('bulk delete', async () => { await followInto(harness, world, world.b.t1.id); diff --git a/fluxer_api/src/api/channel/tests/ForumChannels.test.ts b/fluxer_api/src/api/channel/tests/ForumChannels.test.ts new file mode 100644 index 000000000..5be424ef7 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ForumChannels.test.ts @@ -0,0 +1,687 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createMultipartFormData} from '@app/api/channel/tests/AttachmentTestUtils'; +import { + acceptInvite, + addMemberRole, + createChannel, + createChannelInvite, + createGuild, + createRole, +} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + THREADS_FEATURE, + THREADS_FEATURE_HEADER, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {createEmoji, getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils'; +import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {ChannelFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type { + StartForumThreadResponse, + ThreadPostDataResponse, +} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface Setup { + owner: TestAccount; + member: TestAccount; + guildId: string; + forumId: string; +} + +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +describe('forum and media channels', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function createForum( + token: string, + guildId: string, + body: Record = {}, + type: number = ChannelTypes.GUILD_FORUM, + ): Promise { + return threadsRequest(harness, token) + .post(`/guilds/${guildId}/channels`) + .body({name: 'forum', type, ...body}) + .execute(); + } + + async function setup(forumBody: Record = {}): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + await ensureSessionStarted(harness, owner.token); + await ensureSessionStarted(harness, member.token); + const guild = await createGuild(harness, owner.token, 'forums'); + const general = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, general.id); + await acceptInvite(harness, member.token, invite.code); + const forum = await createForum(owner.token, guild.id, forumBody); + return {owner, member, guildId: guild.id, forumId: forum.id}; + } + + async function post( + token: string, + forumId: string, + body: Record = {}, + status = 201, + ): Promise { + return threadsRequest(harness, token) + .post(`/channels/${forumId}/threads`) + .body({name: 'post', message: {content: 'hello'}, ...body}) + .expect(status) + .execute(); + } + + async function tags(token: string, forumId: string, names: Array<{name: string; moderated?: boolean}>) { + return threadsRequest(harness, token) + .patch(`/channels/${forumId}`) + .body({available_tags: names}) + .execute(); + } + + describe('control arm', () => { + it('rejects a forum create body exactly as before for callers outside the experiment', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'control'); + const body = {name: 'forum', type: ChannelTypes.GUILD_FORUM, available_tags: [{name: 'a'}]}; + const control = await harness.requestJson({ + path: `/guilds/${guild.id}/channels`, + method: 'POST', + headers: {Authorization: owner.token}, + body, + }); + const capable = await harness.requestJson({ + path: `/guilds/${guild.id}/channels`, + method: 'POST', + headers: {Authorization: owner.token, [THREADS_FEATURE_HEADER]: THREADS_FEATURE}, + body, + }); + expect(control.status).toBe(400); + expect(capable.status).toBe(400); + expect(await capable.text()).toBe(await control.text()); + }); + + it('strips the thread defaults of a text channel outside the experiment', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'control'); + const channel = await threadsRequest(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'text', type: ChannelTypes.GUILD_TEXT, default_auto_archive_duration: 60}) + .execute(); + expect(channel).not.toHaveProperty('default_auto_archive_duration'); + const patched = await threadsRequest(harness, owner.token) + .patch(`/channels/${channel.id}`) + .body({default_thread_rate_limit_per_user: 30}) + .execute(); + expect(patched).not.toHaveProperty('default_thread_rate_limit_per_user'); + }); + + it('hides forums from members who are not in the experiment', async () => { + const s = await setup(); + await setChannelThreadsConfig({...ALL_THREADS_ACTIVE, excluded_user_ids: [s.member.userId]}); + await threadsRequest(harness, s.member.token).get(`/channels/${s.forumId}`).expect(404).execute(); + const channels = await threadsRequest>(harness, s.member.token) + .get(`/guilds/${s.guildId}/channels`) + .execute(); + expect(channels.some((channel) => channel.id === s.forumId)).toBe(false); + await createBuilder(harness, s.member.token) + .post(`/channels/${s.forumId}/tags`) + .body({name: 'x'}) + .expect(404) + .execute(); + }); + }); + + describe('settings', () => { + it('creates a forum with its settings and serves them on reads', async () => { + const s = await setup({ + topic: 'x'.repeat(2000), + available_tags: [ + {name: 'bug', emoji_name: '🐛'}, + {name: 'staff', moderated: true}, + ], + default_sort_order: 1, + default_forum_layout: 2, + default_tag_setting: 'match_all', + default_thread_rate_limit_per_user: 10, + default_auto_archive_duration: 1440, + default_reaction_emoji: {emoji_name: '👍'}, + nsfw: true, + rate_limit_per_user: 15, + }); + const forum = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.forumId}`) + .execute(); + expect(forum.type).toBe(ChannelTypes.GUILD_FORUM); + expect(forum.topic).toHaveLength(2000); + expect(forum.available_tags?.map((tag) => [tag.name, tag.moderated, tag.emoji_name])).toEqual([ + ['bug', false, '🐛'], + ['staff', true, null], + ]); + expect(forum).toMatchObject({ + flags: 0, + default_sort_order: 1, + default_forum_layout: 2, + default_tag_setting: 'match_all', + default_thread_rate_limit_per_user: 10, + default_auto_archive_duration: 1440, + default_reaction_emoji: {emoji_id: null, emoji_name: '👍'}, + nsfw: true, + rate_limit_per_user: 15, + }); + expect(forum).not.toHaveProperty('icon'); + expect(forum).not.toHaveProperty('owner_id'); + const listed = await threadsRequest>(harness, s.owner.token) + .get(`/guilds/${s.guildId}/channels`) + .execute(); + expect(listed.find((channel) => channel.id === s.forumId)?.available_tags).toHaveLength(2); + }); + + it('resets the layout and thread slowmode defaults on explicit null', async () => { + const s = await setup({default_forum_layout: null, default_thread_rate_limit_per_user: null}); + const set = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({default_forum_layout: 2, default_thread_rate_limit_per_user: 10}) + .execute(); + expect(set).toMatchObject({default_forum_layout: 2, default_thread_rate_limit_per_user: 10}); + const cleared = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({default_forum_layout: null, default_thread_rate_limit_per_user: null}) + .execute(); + expect(cleared).toMatchObject({default_forum_layout: 0, default_thread_rate_limit_per_user: 0}); + }); + + it('validates tags and flags', async () => { + const s = await setup(); + const tooMany = Array.from({length: 21}, (_, index) => ({name: `t${index}`})); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({available_tags: tooMany}) + .expect(400, APIErrorCodes.MAX_FORUM_TAGS) + .execute(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({available_tags: [{name: 'a'}, {name: 'a'}]}) + .expect(400, APIErrorCodes.FORUM_TAG_NAMES_MUST_BE_UNIQUE) + .execute(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({available_tags: [{id: '123', name: 'a'}]}) + .expect(404, APIErrorCodes.UNKNOWN_FORUM_TAG) + .execute(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({available_tags: [{name: 'a', emoji_name: 'hello'}]}) + .expect(400, APIErrorCodes.INVALID_FORM_BODY) + .execute(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({default_reaction_emoji: {emoji_name: 'hello'}}) + .expect(400, APIErrorCodes.INVALID_FORM_BODY) + .execute(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({available_tags: [{name: 'mods', moderated: true}], flags: ChannelFlags.REQUIRE_TAG}) + .expect(400, APIErrorCodes.NO_TAGS_AVAILABLE_TO_NON_MODERATORS) + .execute(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({flags: ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS}) + .expect(400, APIErrorCodes.HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY) + .execute(); + const media = await createForum(s.owner.token, s.guildId, {flags: ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS}, 16); + expect(media.flags).toBe(ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS); + expect(media).not.toHaveProperty('default_forum_layout'); + }); + + it('keeps tag ids across edits and exposes the tag routes', async () => { + const s = await setup(); + const first = await tags(s.owner.token, s.forumId, [{name: 'a'}, {name: 'b'}]); + const [a, b] = first.available_tags!; + const kept = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({available_tags: [{id: b!.id, name: 'b2'}, {name: 'c'}]}) + .execute(); + expect(kept.available_tags![0]).toMatchObject({id: b!.id, name: 'b2'}); + expect(kept.available_tags!.some((tag) => tag.id === a!.id)).toBe(false); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({ + available_tags: [ + {id: b!.id, name: 'x'}, + {id: b!.id, name: 'y'}, + ], + }) + .expect(404, APIErrorCodes.UNKNOWN_FORUM_TAG) + .execute(); + const created = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.forumId}/tags`) + .body({name: 'd', moderated: true}) + .execute(); + const d = created.available_tags!.find((tag) => tag.name === 'd')!; + const updated = await threadsRequest(harness, s.owner.token) + .put(`/channels/${s.forumId}/tags/${d.id}`) + .body({name: 'e'}) + .execute(); + expect(updated.available_tags!.find((tag) => tag.id === d.id)).toMatchObject({name: 'e', moderated: false}); + const deleted = await threadsRequest(harness, s.owner.token) + .delete(`/channels/${s.forumId}/tags/${d.id}`) + .execute(); + expect(deleted.available_tags!.some((tag) => tag.id === d.id)).toBe(false); + await threadsRequest(harness, s.owner.token) + .delete(`/channels/${s.forumId}/tags/${d.id}`) + .expect(404, APIErrorCodes.UNKNOWN_FORUM_TAG) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.forumId}/tags`) + .body({name: 'x'}) + .expect(403) + .execute(); + }); + + it('keeps tag edits working after a tag emoji is deleted', async () => { + const s = await setup(); + const emoji = await createEmoji(harness, s.owner.token, s.guildId, {name: 'bug', image: getPngDataUrl()}); + const first = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({available_tags: [{name: 'bug', emoji_id: emoji.id}, {name: 'other'}]}) + .execute(); + const [bug, other] = first.available_tags!; + await createBuilder(harness, s.owner.token) + .delete(`/guilds/${s.guildId}/emojis/${emoji.id}`) + .expect(204) + .execute(); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.forumId}/tags`) + .body({name: 'new'}) + .expect(200) + .execute(); + await threadsRequest(harness, s.owner.token) + .delete(`/channels/${s.forumId}/tags/${other!.id}`) + .expect(200) + .execute(); + const kept = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({available_tags: [{id: bug!.id, name: 'bug2', emoji_id: emoji.id}]}) + .execute(); + expect(kept.available_tags).toEqual([expect.objectContaining({id: bug!.id, name: 'bug2', emoji_id: emoji.id})]); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.forumId}/tags`) + .body({name: 'again', emoji_id: emoji.id}) + .expect(404, APIErrorCodes.UNKNOWN_EMOJI) + .execute(); + }); + + it('keeps every tag when tag creates race', async () => { + const s = await setup(); + const names = ['a', 'b', 'c', 'd', 'e']; + await Promise.all( + names.map((name) => + threadsRequest(harness, s.owner.token) + .post(`/channels/${s.forumId}/tags`) + .body({name}) + .execute(), + ), + ); + const forum = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.forumId}`) + .execute(); + expect(forum.available_tags!.map((tag) => tag.name).sort()).toEqual(names); + }); + + it('stores thread defaults on text channels and copies only the rate limit onto new threads', async () => { + const s = await setup(); + const text = await threadsRequest(harness, s.owner.token) + .post(`/guilds/${s.guildId}/channels`) + .body({name: 'text', type: ChannelTypes.GUILD_TEXT, default_auto_archive_duration: 60}) + .execute(); + expect(text.default_auto_archive_duration).toBe(60); + const patched = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${text.id}`) + .body({default_thread_rate_limit_per_user: 30}) + .execute(); + expect(patched).toMatchObject({default_auto_archive_duration: 60, default_thread_rate_limit_per_user: 30}); + const thread = await threadsRequest(harness, s.owner.token) + .post(`/channels/${text.id}/threads`) + .body({name: 't', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + expect(thread.rate_limit_per_user).toBe(30); + expect(thread.thread_metadata?.auto_archive_duration).toBe(4320); + }); + }); + + describe('posts', () => { + it('creates a post whose first message shares the thread id', async () => { + const s = await setup(); + const created = await post(s.member.token, s.forumId); + expect(created.type).toBe(ChannelTypes.PUBLIC_THREAD); + expect(created.parent_id).toBe(s.forumId); + expect(created.message?.id).toBe(created.id); + expect(created.message?.channel_id).toBe(created.id); + expect(created.applied_tags).toEqual([]); + expect(created.message_count).toBe(0); + const forum = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.forumId}`) + .execute(); + expect(forum.last_message_id).toBe(created.id); + const messages = await threadsRequest>(harness, s.member.token) + .get(`/channels/${created.id}/messages`) + .execute(); + expect(messages.map((message) => message.id)).toEqual([created.id]); + const media = await createForum(s.owner.token, s.guildId, {}, ChannelTypes.GUILD_MEDIA); + const mediaPost = await post(s.member.token, media.id); + expect(mediaPost).toMatchObject({type: ChannelTypes.PUBLIC_THREAD, parent_id: media.id, applied_tags: []}); + expect(mediaPost.message?.id).toBe(mediaPost.id); + }); + + it('applies tag rules on create', async () => { + const s = await setup(); + const forum = await tags(s.owner.token, s.forumId, [{name: 'open'}, {name: 'staff', moderated: true}]); + const [open, staff] = forum.available_tags!; + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.forumId}`) + .body({flags: ChannelFlags.REQUIRE_TAG}) + .execute(); + await post(s.member.token, s.forumId, {}, 400); + await post(s.member.token, s.forumId, {applied_tags: ['1']}, 404); + await post(s.member.token, s.forumId, {applied_tags: [staff!.id]}, 403); + const tagged = await post(s.member.token, s.forumId, {applied_tags: [open!.id]}); + expect(tagged.applied_tags).toEqual([open!.id]); + const moderated = await post(s.owner.token, s.forumId, {applied_tags: [staff!.id]}); + expect(moderated.applied_tags).toEqual([staff!.id]); + await threadsRequest(harness, s.member.token) + .patch(`/channels/${tagged.id}`) + .body({applied_tags: [open!.id, staff!.id]}) + .expect(403) + .execute(); + const retagged = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${tagged.id}`) + .body({applied_tags: [staff!.id]}) + .execute(); + expect(retagged.applied_tags).toEqual([staff!.id]); + }); + + it('drops a deleted tag when the post tags are sent back unchanged', async () => { + const s = await setup(); + const forum = await tags(s.owner.token, s.forumId, [{name: 'keep'}, {name: 'gone'}]); + const [keep, gone] = forum.available_tags!; + const created = await post(s.member.token, s.forumId, {applied_tags: [keep!.id, gone!.id]}); + await threadsRequest(harness, s.owner.token).delete(`/channels/${s.forumId}/tags/${gone!.id}`).execute(); + const read = await threadsRequest(harness, s.member.token) + .get(`/channels/${created.id}`) + .execute(); + expect(read.applied_tags).toEqual([keep!.id]); + const edited = await threadsRequest(harness, s.member.token) + .patch(`/channels/${created.id}`) + .body({applied_tags: [keep!.id, gone!.id]}) + .execute(); + expect(edited.applied_tags).toEqual([keep!.id]); + await threadsRequest(harness, s.member.token) + .patch(`/channels/${created.id}`) + .body({applied_tags: [gone!.id]}) + .expect(404, APIErrorCodes.UNKNOWN_FORUM_TAG) + .execute(); + }); + + it('refuses a limited account before creating the post', async () => { + const s = await setup(); + await createBuilder(harness, '') + .post(`/test/users/${s.member.userId}/security-flags`) + .body({set_flags: ['ACCOUNT_LIMITED']}) + .execute(); + const dispatched: Array = []; + const spy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild').mockImplementation(async (params) => { + dispatched.push(params.event); + }); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.forumId}/threads`) + .body({name: 'limited', message: {content: 'hello'}}) + .expect(403, APIErrorCodes.ACCOUNT_LIMITED) + .execute(); + spy.mockRestore(); + expect(dispatched.filter((event) => event.startsWith('THREAD_'))).toEqual([]); + const forum = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.forumId}`) + .execute(); + expect(forum.last_message_id ?? null).toBeNull(); + }); + + it('rejects an invalid starter before creating the post', async () => { + const s = await setup({rate_limit_per_user: 60}); + const dispatched: Array = []; + const spy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild').mockImplementation(async (params) => { + dispatched.push(params.event); + }); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.forumId}/threads`) + .body({name: 'empty', message: {}}) + .expect(400, APIErrorCodes.CANNOT_SEND_EMPTY_MESSAGE) + .execute(); + spy.mockRestore(); + expect(dispatched).not.toContain('THREAD_CREATE'); + const forum = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.forumId}`) + .execute(); + expect(forum.last_message_id ?? null).toBeNull(); + await post(s.member.token, s.forumId); + }); + + it('rolls back a post whose starter fails after the thread exists', async () => { + const s = await setup({rate_limit_per_user: 60}); + const first = await post(s.owner.token, s.forumId); + await post(s.member.token, s.forumId, {message: {content: 'x', sticker_ids: ['1']}}, 400); + const forum = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.forumId}`) + .execute(); + expect(forum.last_message_id).toBe(first.id); + await post(s.member.token, s.forumId); + }); + + it('requires a first message on a JSON post', async () => { + const s = await setup(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.forumId}/threads`) + .body({name: 'post'}) + .expect(400, APIErrorCodes.INVALID_FORM_BODY) + .execute(); + }); + + it('requires send messages on the forum and refuses private posts', async () => { + const s = await setup(); + await post(s.member.token, s.forumId, {type: ChannelTypes.PRIVATE_THREAD}, 400); + const role = await createRole(harness, s.owner.token, s.guildId, {name: 'muted'}); + await addMemberRole(harness, s.owner.token, s.guildId, s.member.userId, role.id); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${s.forumId}/permissions/${role.id}`) + .body({type: 0, allow: '0', deny: Permissions.SEND_MESSAGES.toString()}) + .expect(204) + .execute(); + await post(s.member.token, s.forumId, {}, 403); + }); + + it('accepts a multipart post with a file', async () => { + const s = await setup(); + const {body, contentType} = createMultipartFormData( + {name: 'files', message: {content: 'with file', attachments: [{id: 0, filename: 'a.png'}]}}, + [{index: 0, filename: 'a.png', data: PNG_1X1}], + ); + const response = await harness.app.request(`/channels/${s.forumId}/threads`, { + method: 'POST', + headers: { + Authorization: s.member.token, + [THREADS_FEATURE_HEADER]: THREADS_FEATURE, + 'Content-Type': contentType, + 'x-forwarded-for': '127.0.0.1', + }, + body, + }); + expect(response.status).toBe(201); + const created = (await response.json()) as StartForumThreadResponse; + expect(created.message?.id).toBe(created.id); + expect(created.message?.attachments).toHaveLength(1); + }); + + it('allows one pinned post and clears the pin on archive', async () => { + const s = await setup(); + const first = await post(s.owner.token, s.forumId); + const second = await post(s.owner.token, s.forumId); + const pinned = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${first.id}`) + .body({flags: ChannelFlags.PINNED}) + .execute(); + expect(pinned.flags).toBe(ChannelFlags.PINNED); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${second.id}`) + .body({flags: ChannelFlags.PINNED}) + .expect(400, APIErrorCodes.MAX_PINNED_THREADS_IN_FORUM) + .execute(); + const archived = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${first.id}`) + .body({archived: true}) + .execute(); + expect(archived.flags).toBe(0); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${second.id}`) + .body({flags: ChannelFlags.PINNED}) + .execute(); + await threadsRequest(harness, s.member.token) + .patch(`/channels/${second.id}`) + .body({flags: 0}) + .expect(403) + .execute(); + }); + + it('returns post data', async () => { + const s = await setup(); + const created = await post(s.member.token, s.forumId); + const data = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.forumId}/post-data`) + .body({thread_ids: [created.id, '1']}) + .execute(); + expect(Object.keys(data.threads).sort()).toEqual([created.id, '1'].sort()); + expect(data.threads['1']).toEqual({owner: null, first_message: null}); + expect(data.threads[created.id]?.owner?.user.id).toBe(s.member.userId); + expect(data.threads[created.id]?.first_message?.id).toBe(created.id); + }); + + it('serves the member preview on posts only, newest first', async () => { + const s = await setup(); + const created = await post(s.member.token, s.forumId); + expect(created.member_ids_preview).toEqual([s.member.userId]); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${created.id}/thread-members/@me`) + .expect(204) + .execute(); + const fetched = await threadsRequest(harness, s.owner.token) + .get(`/channels/${created.id}`) + .execute(); + expect(fetched.member_ids_preview).toEqual([s.owner.userId, s.member.userId]); + await threadsRequest(harness, s.owner.token).patch(`/channels/${created.id}`).body({archived: true}).execute(); + const archived = await threadsRequest<{threads: Array}>(harness, s.owner.token) + .get(`/channels/${s.forumId}/threads/archived/public`) + .execute(); + expect(archived.threads.map((thread) => thread.member_ids_preview)).toEqual([[s.owner.userId, s.member.userId]]); + const text = await createChannel(harness, s.owner.token, s.guildId, 'text'); + const textThread = await threadsRequest(harness, s.owner.token) + .post(`/channels/${text.id}/threads`) + .body({name: 'thread', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + expect(textThread).not.toHaveProperty('member_ids_preview'); + }); + + it('applies the forum slowmode to post creation', async () => { + const s = await setup({rate_limit_per_user: 60}); + await post(s.member.token, s.forumId); + const state = await threadsRequest<{retry_after_ms: number}>(harness, s.member.token) + .get(`/channels/${s.forumId}/slowmode`) + .execute(); + expect(state.retry_after_ms).toBeGreaterThan(0); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.forumId}/threads`) + .body({name: 'again', message: {content: 'x'}}) + .expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED) + .execute(); + }); + }); + + describe('gateway payloads', () => { + it('carries forum settings on CHANNEL_CREATE and CHANNEL_UPDATE', async () => { + const s = await setup(); + const dispatches: Array<{event: string; data: unknown}> = []; + const spy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild').mockImplementation(async (params) => { + dispatches.push({event: params.event, data: params.data}); + }); + await createForum(s.owner.token, s.guildId, {available_tags: [{name: 'x'}]}); + await threadsRequest(harness, s.owner.token).post(`/channels/${s.forumId}/tags`).body({name: 'y'}).execute(); + spy.mockRestore(); + const created = dispatches.find((entry) => entry.event === 'CHANNEL_CREATE')?.data as ChannelResponse; + const updated = dispatches.find((entry) => entry.event === 'CHANNEL_UPDATE')?.data as ChannelResponse; + expect(created.available_tags?.map((tag) => tag.name)).toEqual(['x']); + expect(updated.available_tags?.map((tag) => tag.name)).toEqual(['y']); + expect(updated.default_forum_layout).toBe(0); + }); + }); + + describe('existing routes on forums', () => { + it('reports thread search as unavailable without a search provider', async () => { + const s = await setup(); + await threadsRequest(harness, s.member.token) + .get(`/channels/${s.forumId}/threads/search`) + .expect(403, APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) + .execute(); + }); + + it('answers with the right errors', async () => { + const s = await setup(); + await threadsRequest(harness, s.member.token) + .get(`/channels/${s.forumId}/messages`) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + await threadsRequest(harness, s.member.token) + .get(`/channels/${s.forumId}/messages/pins`) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.forumId}/typing`) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.forumId}/messages`) + .body({content: 'x'}) + .expect(400, APIErrorCodes.CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL) + .execute(); + }); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/ThreadCrossCuttingHelpers.test.ts b/fluxer_api/src/api/channel/tests/ThreadCrossCuttingHelpers.test.ts new file mode 100644 index 000000000..786a9ea5d --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ThreadCrossCuttingHelpers.test.ts @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type ChannelID, createChannelID, createGuildID} from '@app/api/BrandedTypes'; +import {MessageContentService} from '@app/api/channel/services/message/MessageContentService'; +import {maskThreadArtifacts} from '@app/api/channel/services/message/ThreadMessageResponses'; +import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope'; +import type {ChannelRow} from '@app/api/database/types/ChannelTypes'; +import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; +import {ChannelHelpers} from '@app/api/guild/services/channel/ChannelHelpers'; +import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; +import {Channel} from '@app/api/models/Channel'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import {GuildExplicitContentFilterTypes} from '@fluxer/constants/src/GuildConstants'; +import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {describe, expect, it} from 'vitest'; + +const GUILD_ID = createGuildID(1_920_000_000_000_000_000n); + +function channel(id: bigint, type: number, overrides: Partial = {}): Channel { + return new Channel({ + channel_id: createChannelID(id), + guild_id: GUILD_ID, + type, + name: `c${id}`, + topic: null, + icon_hash: null, + url: null, + parent_id: null, + position: 0, + owner_id: null, + recipient_ids: null, + nsfw: null, + rate_limit_per_user: 0, + bitrate: null, + user_limit: null, + voice_connection_limit: null, + rtc_region: null, + last_message_id: null, + last_pin_timestamp: null, + permission_overwrites: null, + nicks: null, + soft_deleted: false, + indexed_at: null, + version: 1, + ...overrides, + }); +} + +function message(overrides: Partial): MessageResponse { + return { + id: '10', + channel_id: '1', + author: { + id: '2', + username: 'a', + discriminator: '0001', + global_name: null, + avatar: null, + avatar_color: null, + flags: 0, + }, + type: MessageTypes.DEFAULT, + flags: 0, + content: '', + timestamp: new Date(0).toISOString(), + pinned: false, + mention_everyone: false, + tts: false, + mentions: [], + mention_roles: [], + ...overrides, + }; +} + +describe('thread NSFW scope', () => { + const contentService = new MessageContentService( + {} as IUserRepository, + {} as IGuildRepositoryAggregate, + {} as LimitConfigService, + ); + const guild = { + nsfw_level: 0, + features: [], + explicit_content_filter: GuildExplicitContentFilterTypes.ALL_MEMBERS, + } as unknown as GuildResponse; + + it('resolves a thread to its parent and leaves other channels alone', async () => { + const parent = channel(1n, ChannelTypes.GUILD_TEXT, {nsfw: true}); + const thread = channel(2n, ChannelTypes.PUBLIC_THREAD, {parent_id: parent.id}); + const find = async (id: ChannelID) => (id === parent.id ? parent : null); + expect(await resolveNsfwScopeChannel(thread, find)).toBe(parent); + expect(await resolveNsfwScopeChannel(parent, find)).toBe(parent); + expect(contentService.isNSFWContentAllowed({channel: thread, guild})).toBe(false); + expect(contentService.isNSFWContentAllowed({channel: await resolveNsfwScopeChannel(thread, find), guild})).toBe( + true, + ); + }); + + it('treats forum and media parents like text for NSFW', () => { + expect( + contentService.isNSFWContentAllowed({channel: channel(3n, ChannelTypes.GUILD_FORUM, {nsfw: true}), guild}), + ).toBe(true); + }); +}); + +describe('forum positioning', () => { + it('ranks forums with text channels above voice channels', () => { + const category = channel(1n, ChannelTypes.GUILD_CATEGORY, {position: 1}); + const text = channel(2n, ChannelTypes.GUILD_TEXT, {parent_id: category.id, position: 2}); + const forum = channel(3n, ChannelTypes.GUILD_FORUM, {parent_id: category.id, position: 3}); + const voice = channel(4n, ChannelTypes.GUILD_VOICE, {parent_id: category.id, position: 4}); + const existing = [category, text, forum, voice]; + expect(ChannelHelpers.getNextGlobalChannelPosition(ChannelTypes.GUILD_TEXT, category.id, existing)).toBe(4); + const parents = new Map(existing.map((c) => [c.id, c.parentId])); + expect(() => ChannelHelpers.validateChannelVoicePlacement([category, text, voice, forum], parents)).toThrow(); + expect(() => ChannelHelpers.validateChannelVoicePlacement([category, forum, text, voice], parents)).not.toThrow(); + }); +}); + +describe('thread artifact masking', () => { + it('drops thread created messages and clears thread bits including nested ones', () => { + const masked = maskThreadArtifacts([ + message({id: '1', type: MessageTypes.THREAD_CREATED}), + message({ + id: '2', + flags: ServerMessageFlags.HAS_THREAD, + referenced_message: message({id: '3', flags: ServerMessageFlags.FAILED_TO_MENTION_SOME_ROLES_IN_THREAD}), + }), + message({id: '4', referenced_message: message({id: '5', type: MessageTypes.THREAD_CREATED})}), + ]); + expect(masked.map((m) => m.id)).toEqual(['2', '4']); + expect(masked[0]?.flags).toBe(0); + expect(masked[0]?.referenced_message?.flags).toBe(0); + expect(masked[1]?.referenced_message).toBeNull(); + }); + + it('returns the same array when nothing carries a thread artifact', () => { + const responses = [message({id: '1'})]; + expect(maskThreadArtifacts(responses)).toBe(responses); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/ThreadLimitsAndTimeouts.test.ts b/fluxer_api/src/api/channel/tests/ThreadLimitsAndTimeouts.test.ts new file mode 100644 index 000000000..5f57498f6 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ThreadLimitsAndTimeouts.test.ts @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID, createUserID} from '@app/api/BrandedTypes'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository'; +import { + acceptInvite, + addMemberRole, + createChannel, + createChannelInvite, + createGuild, +} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes, MessageFlags, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {MAX_ACTIVE_THREADS_PER_GUILD, ThreadMemberFlags} from '@fluxer/constants/src/ThreadConstants'; +import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface Setup { + owner: TestAccount; + member: TestAccount; + guildId: string; + channelId: string; +} + +const repository = new ChannelRepository(); + +function threadId(id: string) { + return createChannelID(BigInt(id)); +} + +describe('thread limits and timeouts', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function joinGuild(owner: TestAccount, channelId: string): Promise { + const account = await createTestAccount(harness); + const invite = await createChannelInvite(harness, owner.token, channelId); + await acceptInvite(harness, account.token, invite.code); + await ensureSessionStarted(harness, account.token); + return account; + } + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'threads'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + await ensureSessionStarted(harness, owner.token); + const member = await joinGuild(owner, channel.id); + return {owner, member, guildId: guild.id, channelId: channel.id}; + } + + async function startThread( + token: string, + channelId: string, + body: Record = {name: 'topic', type: ChannelTypes.PUBLIC_THREAD}, + ): Promise { + return threadsRequest(harness, token) + .post(`/channels/${channelId}/threads`) + .body(body) + .expect(201) + .execute(); + } + + async function timeOut(s: Setup, userId: string): Promise { + await threadsRequest(harness, s.owner.token) + .patch(`/guilds/${s.guildId}/members/${userId}`) + .body({communication_disabled_until: new Date(Date.now() + 10 * 60 * 1000).toISOString()}) + .expect(200) + .execute(); + } + + function capActiveThreads() { + return vi.spyOn(ThreadRepository.prototype, 'countActiveThreads').mockResolvedValue(MAX_ACTIVE_THREADS_PER_GUILD); + } + + describe('active thread cap', () => { + it('refuses new threads at the cap without writing a thread, a member or a slowmode charge', async () => { + const s = await setup(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.channelId}`) + .body({rate_limit_per_user: 60}) + .expect(200) + .execute(); + const message = await sendMessage(harness, s.owner.token, s.channelId, 'x'); + const cap = capActiveThreads(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/threads`) + .body({name: 'capped', type: ChannelTypes.PUBLIC_THREAD}) + .expect(400, APIErrorCodes.MAX_ACTIVE_THREADS) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: 'capped'}) + .expect(400, APIErrorCodes.MAX_ACTIVE_THREADS) + .execute(); + cap.mockRestore(); + expect(await repository.threads.listActiveThreads(createGuildID(BigInt(s.guildId)))).toEqual([]); + expect(await repository.threads.getState(threadId(message.id))).toBeNull(); + expect(await repository.threads.listMembers(threadId(message.id), {limit: 10})).toEqual([]); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: 'allowed'}) + .expect(201) + .execute(); + }); + + it('refuses unarchiving by PATCH and by send at the cap', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await repository.threads.updateState(threadId(thread.id), () => ({archived: true})); + const cap = capActiveThreads(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${thread.id}`) + .body({archived: false}) + .expect(400, APIErrorCodes.MAX_ACTIVE_THREADS) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'wake up'}) + .expect(400, APIErrorCodes.MAX_ACTIVE_THREADS) + .execute(); + cap.mockRestore(); + expect((await repository.threads.getState(threadId(thread.id)))?.archived).toBe(true); + expect(await repository.threads.getMember(threadId(thread.id), createUserID(BigInt(s.member.userId)))).toBeNull(); + const messages = await threadsRequest>(harness, s.owner.token) + .get(`/channels/${thread.id}/messages`) + .execute(); + expect(messages.map((m) => m.content)).not.toContain('wake up'); + }); + }); + + describe('timeouts', () => { + it('blocks thread writes for a timed-out member but still lets them join, leave, change settings and read', async () => { + const s = await setup(); + const own = await startThread(s.member.token, s.channelId, {name: 'mine', type: ChannelTypes.PUBLIC_THREAD}); + await threadsRequest(harness, s.owner.token).put(`/channels/${own.id}/thread-members/@me`).expect(204).execute(); + const other = await startThread(s.owner.token, s.channelId); + await timeOut(s, s.member.userId); + const denied = [ + threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/threads`) + .body({name: 'new', type: ChannelTypes.PUBLIC_THREAD}), + threadsRequest(harness, s.member.token).patch(`/channels/${own.id}`).body({name: 'renamed'}), + threadsRequest(harness, s.member.token).put(`/channels/${other.id}/thread-members/${s.owner.userId}`), + threadsRequest(harness, s.member.token).delete(`/channels/${own.id}/thread-members/${s.owner.userId}`), + threadsRequest(harness, s.member.token).delete(`/channels/${own.id}`), + ]; + for (const request of denied) { + await request.expect(403, APIErrorCodes.COMMUNICATION_DISABLED).execute(); + } + await threadsRequest(harness, s.member.token) + .put(`/channels/${other.id}/thread-members/@me`) + .expect(204) + .execute(); + await threadsRequest(harness, s.member.token) + .patch(`/channels/${other.id}/thread-members/@me/settings`) + .body({flags: ThreadMemberFlags.ONLY_MENTIONS}) + .expect(200) + .execute(); + await threadsRequest(harness, s.member.token).get(`/channels/${other.id}/messages`).expect(200).execute(); + await threadsRequest(harness, s.member.token) + .delete(`/channels/${other.id}/thread-members/@me`) + .expect(204) + .execute(); + expect((await repository.threads.getState(threadId(own.id)))?.archived).toBe(false); + }); + + it('blocks adding reactions and editing own messages but not removals or moderator edits', async () => { + const s = await setup(); + const role = await threadsRequest(harness, s.owner.token) + .post(`/guilds/${s.guildId}/roles`) + .body({name: 'mods', permissions: Permissions.MANAGE_MESSAGES.toString()}) + .execute(); + await addMemberRole(harness, s.owner.token, s.guildId, s.member.userId, role.id); + const thread = await startThread(s.owner.token, s.channelId); + const ownerMessage = await threadsRequest(harness, s.owner.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'owner line'}) + .execute(); + const memberMessage = await threadsRequest(harness, s.member.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'member line'}) + .execute(); + const reaction = `/channels/${thread.id}/messages/${ownerMessage.id}/reactions/${encodeURIComponent('👍')}/@me`; + await threadsRequest(harness, s.member.token).put(reaction).expect(204).execute(); + await timeOut(s, s.member.userId); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/messages/${memberMessage.id}/reactions/${encodeURIComponent('👍')}/@me`) + .expect(403, APIErrorCodes.COMMUNICATION_DISABLED) + .execute(); + await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}/messages/${memberMessage.id}`) + .body({content: 'edited'}) + .expect(403, APIErrorCodes.COMMUNICATION_DISABLED) + .execute(); + await threadsRequest(harness, s.member.token).delete(reaction).expect(204).execute(); + const suppressed = await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}/messages/${ownerMessage.id}`) + .body({flags: MessageFlags.SUPPRESS_EMBEDS}) + .expect(200) + .execute(); + expect(suppressed.flags & MessageFlags.SUPPRESS_EMBEDS).not.toBe(0); + }); + + it('hides a private thread from a timed-out thread moderator who is not a member', async () => { + const s = await setup(); + const role = await threadsRequest(harness, s.owner.token) + .post(`/guilds/${s.guildId}/roles`) + .body({name: 'mods', permissions: ThreadPermissionFlags.MANAGE_THREADS.toString()}) + .execute(); + await addMemberRole(harness, s.owner.token, s.guildId, s.member.userId, role.id); + const thread = await startThread(s.owner.token, s.channelId, {name: 'p', type: ChannelTypes.PRIVATE_THREAD}); + await threadsRequest(harness, s.member.token).get(`/channels/${thread.id}`).expect(200).execute(); + await timeOut(s, s.member.userId); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/@me`) + .expect(403, APIErrorCodes.MISSING_ACCESS) + .execute(); + }); + }); + + describe('null patch fields', () => { + it('treats null archived, locked and invitable as no change', async () => { + const s = await setup(); + const thread = await startThread(s.member.token, s.channelId); + const patched = await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}`) + .body({archived: null, locked: null, rate_limit_per_user: null, name: 'renamed'}) + .expect(200) + .execute(); + expect(patched.name).toBe('renamed'); + expect(patched.thread_metadata?.archived).toBe(false); + expect(patched.thread_metadata?.locked).toBe(false); + expect(patched.rate_limit_per_user ?? 0).toBe(thread.rate_limit_per_user ?? 0); + const privateThread = await startThread(s.owner.token, s.channelId, { + name: 'private', + type: ChannelTypes.PRIVATE_THREAD, + invitable: false, + }); + const privatePatched = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${privateThread.id}`) + .body({invitable: null, locked: null}) + .expect(200) + .execute(); + expect(privatePatched.thread_metadata?.invitable).toBe(false); + expect(privatePatched.thread_metadata?.locked).toBe(false); + }); + + it('leaves the channel row untouched when the thread state vanishes mid patch', async () => { + const s = await setup(); + const thread = await startThread(s.member.token, s.channelId); + vi.spyOn(ThreadRepository.prototype, 'updateState').mockResolvedValueOnce(null); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${thread.id}`) + .body({name: 'renamed', rate_limit_per_user: 30}) + .expect(404, APIErrorCodes.UNKNOWN_CHANNEL) + .execute(); + const stored = await repository.findUnique(threadId(thread.id)); + expect(stored?.name).toBe('topic'); + expect(stored?.rateLimitPerUser ?? 0).toBe(thread.rate_limit_per_user ?? 0); + }); + + it('clears slowmode on null rate_limit_per_user for moderators only', async () => { + const s = await setup(); + const thread = await startThread(s.member.token, s.channelId); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${thread.id}`) + .body({rate_limit_per_user: 30}) + .expect(200) + .execute(); + await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}`) + .body({rate_limit_per_user: null}) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + const cleared = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${thread.id}`) + .body({rate_limit_per_user: null}) + .expect(200) + .execute(); + expect(cleared.rate_limit_per_user ?? 0).toBe(0); + }); + }); + + describe('unarchive by members', () => { + it('lets a member who is not the owner unarchive without send permission and refuses non-members', async () => { + const s = await setup(); + const outsider = await joinGuild(s.owner, s.channelId); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/@me`) + .expect(204) + .execute(); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${s.channelId}/permissions/${s.guildId}`) + .body({type: 0, allow: '0', deny: ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS.toString()}) + .expect(204) + .execute(); + await repository.threads.updateState(threadId(thread.id), () => ({archived: true})); + await threadsRequest(harness, outsider.token) + .patch(`/channels/${thread.id}`) + .body({archived: false}) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + const reopened = await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}`) + .body({ + archived: false, + name: thread.name, + auto_archive_duration: thread.thread_metadata!.auto_archive_duration, + rate_limit_per_user: thread.rate_limit_per_user ?? 0, + }) + .expect(200) + .execute(); + expect(reopened.thread_metadata?.archived).toBe(false); + await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}`) + .body({name: 'renamed'}) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + }); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/ThreadMemberSettings.test.ts b/fluxer_api/src/api/channel/tests/ThreadMemberSettings.test.ts new file mode 100644 index 000000000..87305ff7d --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ThreadMemberSettings.test.ts @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createUserID} from '@app/api/BrandedTypes'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository'; +import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ThreadMemberFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +const repository = new ChannelRepository(); + +describe('thread member settings', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup() { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'threads'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, channel.id); + await acceptInvite(harness, member.token, invite.code); + await ensureSessionStarted(harness, owner.token); + await ensureSessionStarted(harness, member.token); + const thread = await threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'topic', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + return {owner, member, thread}; + } + + function settings(token: string, threadId: string, body: Record, status = 200, code?: string) { + return threadsRequest(harness, token) + .patch(`/channels/${threadId}/thread-members/@me/settings`) + .body(body) + .expect(status, code) + .execute(); + } + + it('is an unregistered path outside the experiment', async () => { + const s = await setup(); + await threadsRequest(harness, s.owner.token, {capable: false}) + .patch(`/channels/${s.thread.id}/thread-members/@me/settings`) + .body({flags: ThreadMemberFlags.ALL_MESSAGES}) + .expect(404) + .execute(); + await setChannelThreadsConfig({enabled: false}); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.thread.id}/thread-members/@me/settings`) + .body({flags: ThreadMemberFlags.ALL_MESSAGES}) + .expect(404) + .execute(); + }); + + it('updates flags and mute, keeps HAS_INTERACTED and tells only the member', async () => { + const s = await setup(); + const threadId = createChannelID(BigInt(s.thread.id)); + const ownerId = createUserID(BigInt(s.owner.userId)); + const joined = await repository.threads.getMember(threadId, ownerId); + await repository.threads.updateMemberSettings(joined!, {flags: ThreadMemberFlags.HAS_INTERACTED}); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const updated = await settings(s.owner.token, s.thread.id, { + flags: ThreadMemberFlags.ONLY_MENTIONS, + muted: true, + mute_config: {selected_time_window: 3600, end_time: '2030-01-01T00:00:00.000Z'}, + }); + expect(updated).toMatchObject({ + id: s.thread.id, + user_id: s.owner.userId, + flags: ThreadMemberFlags.HAS_INTERACTED | ThreadMemberFlags.ONLY_MENTIONS, + muted: true, + mute_config: {selected_time_window: 3600, end_time: '2030-01-01T00:00:00.000Z'}, + }); + const events = dispatch.mock.calls.map(([params]) => params); + expect(events).toEqual([ + expect.objectContaining({ + event: 'THREAD_MEMBER_UPDATE', + data: expect.objectContaining({user_id: s.owner.userId, guild_id: s.thread.guild_id}), + }), + ]); + const stored = await repository.threads.getMember(threadId, ownerId); + expect(stored?.muted).toBe(true); + }); + + it('answers 204 without an event when nothing changes', async () => { + const s = await setup(); + await settings(s.owner.token, s.thread.id, {flags: ThreadMemberFlags.NO_MESSAGES}); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.thread.id}/thread-members/@me/settings`) + .body({flags: ThreadMemberFlags.NO_MESSAGES}) + .expect(204) + .execute(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('rejects HAS_INTERACTED and more than one notification level', async () => { + const s = await setup(); + await settings( + s.owner.token, + s.thread.id, + {flags: ThreadMemberFlags.HAS_INTERACTED}, + 400, + APIErrorCodes.INVALID_THREAD_NOTIFICATION_SETTINGS, + ); + await settings( + s.owner.token, + s.thread.id, + {flags: ThreadMemberFlags.ALL_MESSAGES | ThreadMemberFlags.NO_MESSAGES}, + 400, + APIErrorCodes.INVALID_THREAD_NOTIFICATION_SETTINGS, + ); + }); + + it('retries a settings write that lost the member race', async () => { + const s = await setup(); + const write = vi.spyOn(ThreadRepository.prototype, 'updateMemberSettings').mockResolvedValueOnce(null); + const updated = await settings(s.owner.token, s.thread.id, {muted: true}); + expect(updated.muted).toBe(true); + expect(write).toHaveBeenCalledTimes(2); + const stored = await repository.threads.getMember( + createChannelID(BigInt(s.thread.id)), + createUserID(BigInt(s.owner.userId)), + ); + expect(stored?.muted).toBe(true); + }); + + it('rejects a settings write computed from stale member flags', async () => { + const s = await setup(); + const threadId = createChannelID(BigInt(s.thread.id)); + const ownerId = createUserID(BigInt(s.owner.userId)); + const joined = await repository.threads.getMember(threadId, ownerId); + const stale = await repository.threads.updateMemberSettings(joined!, {flags: 0}); + expect( + await repository.threads.updateMemberSettings(stale!, {flags: ThreadMemberFlags.HAS_INTERACTED}), + ).not.toBeNull(); + expect(await repository.threads.updateMemberSettings(stale!, {flags: ThreadMemberFlags.ALL_MESSAGES})).toBeNull(); + expect((await repository.threads.getMember(threadId, ownerId))?.flags).toBe(ThreadMemberFlags.HAS_INTERACTED); + const updated = await settings(s.owner.token, s.thread.id, {flags: ThreadMemberFlags.ALL_MESSAGES}); + expect(updated.flags).toBe(ThreadMemberFlags.HAS_INTERACTED | ThreadMemberFlags.ALL_MESSAGES); + }); + + it('leaves a mute config it was not asked to change to a concurrent writer', async () => { + const s = await setup(); + const threadId = createChannelID(BigInt(s.thread.id)); + const ownerId = createUserID(BigInt(s.owner.userId)); + const stale = await repository.threads.getMember(threadId, ownerId); + const muteConfig = {end_time: new Date('2030-01-01T00:00:00.000Z'), selected_time_window: 3600}; + expect(await repository.threads.updateMemberSettings(stale!, {muteConfig})).not.toBeNull(); + const write = vi.spyOn(ThreadRepository.prototype, 'updateMemberSettings'); + vi.spyOn(ThreadRepository.prototype, 'getMember').mockResolvedValueOnce(stale); + await settings(s.owner.token, s.thread.id, {muted: true}); + expect(write.mock.calls.map(([, patch]) => patch)).toEqual([{flags: stale!.flags, muted: true}]); + const stored = await repository.threads.getMember(threadId, ownerId); + expect(stored?.muted).toBe(true); + expect(stored?.muteConfig?.toMuteConfig()).toEqual(muteConfig); + }); + + it('requires membership', async () => { + const s = await setup(); + await settings(s.member.token, s.thread.id, {muted: true}, 404, APIErrorCodes.UNKNOWN_THREAD_MEMBER); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/ThreadMessageLifecycle.test.ts b/fluxer_api/src/api/channel/tests/ThreadMessageLifecycle.test.ts new file mode 100644 index 000000000..3d1135432 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ThreadMessageLifecycle.test.ts @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository'; +import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ServerMessageFlags, ThreadMemberFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface Setup { + owner: TestAccount; + member: TestAccount; + guildId: string; + channelId: string; +} + +const repository = new ChannelRepository(); + +describe('thread message lifecycle', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'lifecycle'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, channel.id); + await acceptInvite(harness, member.token, invite.code); + await ensureSessionStarted(harness, owner.token); + await ensureSessionStarted(harness, member.token); + return {owner, member, guildId: guild.id, channelId: channel.id}; + } + + async function threadFromMessage(s: Setup, sourceId: string) { + return threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/messages/${sourceId}/threads`) + .body({name: 'discussion'}) + .expect(201) + .execute(); + } + + async function send(token: string, channelId: string, content: string) { + return threadsRequest(harness, token) + .post(`/channels/${channelId}/messages`) + .body({content}) + .execute(); + } + + async function page(token: string, channelId: string, query: string) { + return threadsRequest>(harness, token) + .get(`/channels/${channelId}/messages?${query}`) + .execute(); + } + + function hasStarter(messages: Array): boolean { + return messages.some((message) => message.type === MessageTypes.THREAD_STARTER_MESSAGE); + } + + async function messageCount(token: string, threadId: string): Promise { + const thread = await threadsRequest(harness, token).get(`/channels/${threadId}`).execute(); + return thread.message_count; + } + + it('adds the starter only to pages that reach the start of the thread', async () => { + const s = await setup(); + const source = await sendMessage(harness, s.member.token, s.channelId, 'source'); + const thread = await threadFromMessage(s, source.id); + const sent: Array = []; + for (const content of ['one', 'two', 'three', 'four']) sent.push(await send(s.member.token, thread.id, content)); + const [first, , , last] = sent; + expect(hasStarter(await page(s.member.token, thread.id, 'limit=2'))).toBe(false); + expect(hasStarter(await page(s.member.token, thread.id, `around=${last!.id}&limit=3`))).toBe(false); + expect(hasStarter(await page(s.member.token, thread.id, `around=${first!.id}&limit=3`))).toBe(true); + expect(await page(s.member.token, thread.id, `before=${first!.id}`)).toMatchObject([ + {id: thread.id, type: MessageTypes.THREAD_STARTER_MESSAGE}, + ]); + const latest = await page(s.member.token, thread.id, 'limit=50'); + expect(latest).toHaveLength(5); + expect(latest.at(-1)?.type).toBe(MessageTypes.THREAD_STARTER_MESSAGE); + }); + + it('sends one member update with current flags when a send unarchives the thread', async () => { + const s = await setup(); + const source = await sendMessage(harness, s.owner.token, s.channelId, 'source'); + const thread = await threadFromMessage(s, source.id); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/@me`) + .expect(204) + .execute(); + await repository.threads.updateState(createChannelID(BigInt(thread.id)), () => ({archived: true})); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await send(s.member.token, thread.id, 'back again'); + const events = dispatch.mock.calls.map(([params]) => params); + expect(events.filter((event) => event.event === 'THREAD_MEMBER_UPDATE')).toEqual([]); + const update = events.find((event) => event.event === 'THREAD_UPDATE')?.data as { + _fluxer_members?: Array<{user_id: string; flags: number}>; + }; + expect(update._fluxer_members?.find((member) => member.user_id === s.member.userId)?.flags).toBe( + ThreadMemberFlags.HAS_INTERACTED, + ); + }); + + it('clears HAS_THREAD on the source when the thread is deleted and allows a new thread', async () => { + const s = await setup(); + const source = await sendMessage(harness, s.member.token, s.channelId, 'source'); + const thread = await threadFromMessage(s, source.id); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await threadsRequest(harness, s.owner.token).delete(`/channels/${thread.id}`).expect(204).execute(); + const events = dispatch.mock.calls.map(([params]) => params); + expect(events.map((event) => event.event).slice(0, 2)).toEqual(['THREAD_DELETE', 'MESSAGE_UPDATE']); + expect(events[1]?.data).toMatchObject({id: source.id, __thread_only_update: true}); + const stored = await repository.messages.getMessage( + createChannelID(BigInt(s.channelId)), + createMessageID(BigInt(source.id)), + ); + expect((stored!.flags & ServerMessageFlags.HAS_THREAD) === 0).toBe(true); + await threadFromMessage(s, source.id); + }); + + it('leaves a source deleted during thread creation deleted', async () => { + const s = await setup(); + const source = await sendMessage(harness, s.member.token, s.channelId, 'source'); + const channelId = createChannelID(BigInt(s.channelId)); + const messageId = createMessageID(BigInt(source.id)); + const create = ThreadRepository.prototype.create; + vi.spyOn(ThreadRepository.prototype, 'create').mockImplementation(async function (this: ThreadRepository, params) { + await repository.messages.deleteMessage(channelId, messageId, createUserID(BigInt(s.member.userId))); + return create.call(this, params); + }); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await threadFromMessage(s, source.id); + expect(await repository.messages.getMessage(channelId, messageId)).toBeNull(); + const events = dispatch.mock.calls.map(([params]) => params); + expect(events.some((event) => event.event === 'MESSAGE_UPDATE')).toBe(false); + }); + + it('keeps the thread message count in step with admin and bulk deletes', async () => { + const s = await setup(); + const source = await sendMessage(harness, s.owner.token, s.channelId, 'source'); + const thread = await threadFromMessage(s, source.id); + const first = await send(s.member.token, thread.id, 'first'); + const second = await send(s.member.token, thread.id, 'second'); + expect(await messageCount(s.member.token, thread.id)).toBe(2); + const admin = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]); + await createBuilder(harness, admin.token) + .delete(`/admin/channels/${thread.id}/messages/${first.id}`) + .expect(200) + .execute(); + expect(await messageCount(s.member.token, thread.id)).toBe(1); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const unknownId = (BigInt(second.id) + 1n).toString(); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${thread.id}/messages/bulk-delete`) + .body({message_ids: [second.id, unknownId]}) + .expect(204) + .execute(); + expect(await messageCount(s.member.token, thread.id)).toBe(0); + const bulk = dispatch.mock.calls.find(([params]) => params.event === 'MESSAGE_DELETE_BULK')?.[0].data; + expect(bulk).toMatchObject({ids: [second.id]}); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/ThreadMessagePath.test.ts b/fluxer_api/src/api/channel/tests/ThreadMessagePath.test.ts new file mode 100644 index 000000000..df52049ee --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ThreadMessagePath.test.ts @@ -0,0 +1,703 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID, createMessageID, createUserID} from '@app/api/BrandedTypes'; +import {authorizeBot, createTestBotAccount} from '@app/api/bot/tests/BotTestUtils'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService'; +import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {upsertOne} from '@app/api/database/CassandraQueryExecution'; +import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {getKVThreadAutoArchiveQueue} from '@app/api/middleware/ServiceSingletons'; +import {ThreadState as ThreadStateTable} from '@app/api/Tables'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder'; +import {createWebhook} from '@app/api/webhook/tests/WebhookTestUtils'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; +import {ChannelTypes, MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {MAX_THREAD_MEMBERS, ServerMessageFlags, ThreadMemberFlags} from '@fluxer/constants/src/ThreadConstants'; +import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {GuildAuditLogListResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface Setup { + owner: TestAccount; + member: TestAccount; + guildId: string; + channelId: string; +} + +const repository = new ChannelRepository(); + +describe('thread message path', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'threads'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, channel.id); + await acceptInvite(harness, member.token, invite.code); + await ensureSessionStarted(harness, owner.token); + await ensureSessionStarted(harness, member.token); + return {owner, member, guildId: guild.id, channelId: channel.id}; + } + + async function startThread(token: string, channelId: string, type: number = ChannelTypes.PUBLIC_THREAD) { + return threadsRequest(harness, token) + .post(`/channels/${channelId}/threads`) + .body({name: 'topic', type}) + .expect(201) + .execute(); + } + + async function send(token: string, channelId: string, content: string, status = 200, code?: string) { + return threadsRequest(harness, token) + .post(`/channels/${channelId}/messages`) + .body({content}) + .expect(status, code) + .execute(); + } + + async function getThread(token: string, threadId: string) { + return threadsRequest(harness, token).get(`/channels/${threadId}`).execute(); + } + + async function listMessages(token: string, channelId: string, capable = true) { + return threadsRequest>(harness, token, {capable}) + .get(`/channels/${channelId}/messages`) + .execute(); + } + + function threadState(threadId: string) { + return repository.threads.getState(createChannelID(BigInt(threadId))); + } + + describe('sending', () => { + it('auto-joins the sender, counts the message and keeps ids above the thread id', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const message = await send(s.member.token, thread.id, 'hello'); + expect(BigInt(message.id)).toBeGreaterThan(BigInt(thread.id)); + const member = await repository.threads.getMember( + createChannelID(BigInt(thread.id)), + createUserID(BigInt(s.member.userId)), + ); + expect(member?.flags).toBe(ThreadMemberFlags.HAS_INTERACTED); + const fetched = await getThread(s.member.token, thread.id); + expect(fetched.message_count).toBe(1); + expect(fetched.total_message_sent).toBe(1); + expect(fetched.last_message_id).toBe(message.id); + }); + + it('never auto-joins a bot that sends into a thread', async () => { + const s = await setup(); + const bot = await createTestBotAccount(harness); + await authorizeBot(harness, s.owner.token, bot.appId, ['bot'], s.guildId, Permissions.ADMINISTRATOR.toString()); + const thread = await startThread(s.owner.token, s.channelId); + await send(`Bot ${bot.botToken}`, thread.id, 'bot line'); + const member = await repository.threads.getMember( + createChannelID(BigInt(thread.id)), + createUserID(BigInt(bot.botUserId)), + ); + expect(member).toBeNull(); + }); + + it('unarchives an archived unlocked thread on send and refuses a locked one', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await repository.threads.updateState(createChannelID(BigInt(thread.id)), () => ({archived: true})); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await send(s.member.token, thread.id, 'wake up'); + expect((await threadState(thread.id))?.archived).toBe(false); + const events = dispatch.mock.calls.map(([params]) => params.event); + expect(events.indexOf('THREAD_UPDATE')).toBeLessThan(events.indexOf('MESSAGE_CREATE')); + const update = dispatch.mock.calls.find(([params]) => params.event === 'THREAD_UPDATE')?.[0].data as { + _fluxer_members?: Array<{user_id: string}>; + }; + expect(update._fluxer_members?.map((member) => member.user_id)).toEqual([s.owner.userId]); + await repository.threads.updateState(createChannelID(BigInt(thread.id)), () => ({archived: true, locked: true})); + await send(s.member.token, thread.id, 'locked out', 400, APIErrorCodes.THREAD_LOCKED); + await send(s.owner.token, thread.id, 'moderator'); + expect((await threadState(thread.id))?.archived).toBe(false); + }); + + it('checks SEND_MESSAGES_IN_THREADS instead of SEND_MESSAGES', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${s.channelId}/permissions/${s.guildId}`) + .body({ + type: 0, + allow: '0', + deny: ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS.toString(), + }) + .expect(204) + .execute(); + await send(s.member.token, thread.id, 'nope', 403, APIErrorCodes.MISSING_PERMISSIONS); + await sendMessage(harness, s.member.token, s.channelId, 'parent still works'); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${s.channelId}/permissions/${s.guildId}`) + .body({type: 0, allow: '0', deny: Permissions.SEND_MESSAGES.toString()}) + .expect(204) + .execute(); + await send(s.member.token, thread.id, 'thread works'); + }); + + it('applies the thread slowmode and refuses forwarding thread created messages', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${thread.id}`) + .body({rate_limit_per_user: 30}) + .execute(); + await send(s.member.token, thread.id, 'first'); + await send(s.member.token, thread.id, 'second', 400, APIErrorCodes.SLOWMODE_RATE_LIMITED); + const typing = await threadsRequest<{message_send_cooldown_ms?: number}>(harness, s.member.token) + .post(`/channels/${thread.id}/typing`) + .expect(200) + .execute(); + expect(typing.message_send_cooldown_ms).toBeGreaterThan(0); + const created = (await listMessages(s.owner.token, s.channelId)).find( + (message) => message.type === MessageTypes.THREAD_CREATED, + ); + expect(created).toBeDefined(); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/messages`) + .body({message_reference: {type: 1, channel_id: s.channelId, message_id: created!.id}}) + .expect(400) + .execute(); + }); + + it('adds mentioned members who can see the parent', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await send(s.owner.token, thread.id, `hey <@${s.member.userId}>`); + const member = await repository.threads.getMember( + createChannelID(BigInt(thread.id)), + createUserID(BigInt(s.member.userId)), + ); + expect(member?.flags).toBe(0); + const bot = await createTestBotAccount(harness); + await authorizeBot(harness, s.owner.token, bot.appId, ['bot'], s.guildId, Permissions.ADMINISTRATOR.toString()); + await setChannelThreadsConfig({...ALL_THREADS_ACTIVE, user_basis_points: 0, included_user_ids: [s.owner.userId]}); + await send(s.owner.token, thread.id, `hey <@${bot.botUserId}>`); + const botMember = await repository.threads.getMember( + createChannelID(BigInt(thread.id)), + createUserID(BigInt(bot.botUserId)), + ); + expect(botMember?.flags).toBe(0); + }); + + it('indexes thread messages for search while the parent is still unindexed', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + expect((await repository.findUnique(createChannelID(BigInt(s.channelId))))?.indexedAt).toBeNull(); + const indexMessage = vi.spyOn(MessageSearchService.prototype, 'indexMessage'); + const message = await send(s.member.token, thread.id, 'searchable'); + expect(indexMessage).toHaveBeenCalledWith( + expect.objectContaining({id: createMessageID(BigInt(message.id))}), + false, + {includeDefault: true}, + ); + }); + }); + + describe('member cap', () => { + async function fillThread(threadId: string, memberCount: number) { + await upsertOne( + ThreadStateTable.patchByPk( + {thread_id: createChannelID(BigInt(threadId))}, + {member_count: {kind: 'set', value: memberCount}}, + ), + ); + } + + it('sends into a full thread without auto-joining the sender', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await repository.threads.updateState(createChannelID(BigInt(thread.id)), () => ({archived: true})); + await fillThread(thread.id, MAX_THREAD_MEMBERS); + await send(s.member.token, thread.id, 'still here'); + const member = await repository.threads.getMember( + createChannelID(BigInt(thread.id)), + createUserID(BigInt(s.member.userId)), + ); + expect(member).toBeNull(); + const state = await threadState(thread.id); + expect(state?.archived).toBe(false); + expect(state?.memberCount).toBe(MAX_THREAD_MEMBERS); + }); + + it('adds mentioned members up to the remaining capacity', async () => { + const s = await setup(); + const third = await createTestAccount(harness); + await acceptInvite(harness, third.token, (await createChannelInvite(harness, s.owner.token, s.channelId)).code); + const thread = await startThread(s.owner.token, s.channelId); + await fillThread(thread.id, MAX_THREAD_MEMBERS - 1); + await send(s.owner.token, thread.id, `hey <@${s.member.userId}> <@${third.userId}>`); + const members = await repository.threads.getMembers(createChannelID(BigInt(thread.id)), [ + createUserID(BigInt(s.member.userId)), + createUserID(BigInt(third.userId)), + ]); + expect(members).toHaveLength(1); + expect((await threadState(thread.id))?.memberCount).toBe(MAX_THREAD_MEMBERS); + }); + }); + + describe('reading', () => { + it('attaches thread to the source for viewers and masks it for everyone else', async () => { + const s = await setup(); + const source = await sendMessage(harness, s.member.token, s.channelId, 'source'); + const thread = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/messages/${source.id}/threads`) + .body({name: 'discussion'}) + .expect(201) + .execute(); + const viewerSource = (await listMessages(s.member.token, s.channelId)).find((m) => m.id === source.id); + expect(viewerSource?.flags ?? 0).toBe(ServerMessageFlags.HAS_THREAD); + expect(viewerSource?.thread?.id).toBe(thread.id); + const controlSource = (await listMessages(s.member.token, s.channelId, false)).find((m) => m.id === source.id); + expect(controlSource?.flags).toBe(0); + expect(controlSource?.thread).toBeUndefined(); + const controlReply = await threadsRequest(harness, s.member.token, {capable: false}) + .post(`/channels/${s.channelId}/messages`) + .body({content: 'reply', message_reference: {message_id: source.id}}) + .execute(); + expect(controlReply.referenced_message?.flags).toBe(0); + const viewerReply = await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages`) + .body({content: 'reply', message_reference: {message_id: source.id}}) + .execute(); + expect(viewerReply.referenced_message?.flags).toBe(ServerMessageFlags.HAS_THREAD); + const forward = await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages`) + .body({message_reference: {type: 1, channel_id: s.channelId, message_id: source.id}}) + .execute(); + expect(forward.message_snapshots?.[0]?.flags).toBe(0); + }); + + it('synthesizes the starter and keeps the thread when the source is deleted', async () => { + const s = await setup(); + const source = await sendMessage(harness, s.member.token, s.channelId, 'source'); + const thread = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/messages/${source.id}/threads`) + .body({name: 'discussion'}) + .expect(201) + .execute(); + await send(s.member.token, thread.id, 'reply'); + const before = await listMessages(s.member.token, thread.id); + const starter = before.at(-1)!; + expect(starter).toMatchObject({id: thread.id, type: MessageTypes.THREAD_STARTER_MESSAGE}); + expect(starter.referenced_message?.id).toBe(source.id); + const older = await threadsRequest>(harness, s.member.token) + .get(`/channels/${thread.id}/messages?before=${thread.id}`) + .execute(); + expect(older).toEqual([]); + await threadsRequest(harness, s.member.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'reply', message_reference: {message_id: thread.id}}) + .expect(400) + .execute(); + await createBuilder(harness, s.member.token) + .delete(`/channels/${s.channelId}/messages/${source.id}`) + .expect(204) + .execute(); + const single = await threadsRequest(harness, s.member.token) + .get(`/channels/${thread.id}/messages/${thread.id}`) + .execute(); + expect(single.type).toBe(MessageTypes.THREAD_STARTER_MESSAGE); + expect(single.referenced_message).toBeNull(); + await threadsRequest(harness, s.owner.token) + .delete(`/channels/${thread.id}/messages/${thread.id}`) + .expect(403) + .execute(); + expect(await threadState(thread.id)).not.toBeNull(); + }); + + it('decrements the message count on delete and counts system messages', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const message = await send(s.member.token, thread.id, 'bye'); + await threadsRequest(harness, s.member.token) + .delete(`/channels/${thread.id}/messages/${message.id}`) + .expect(204) + .execute(); + const fetched = await getThread(s.member.token, thread.id); + expect(fetched.message_count).toBe(0); + expect(fetched.total_message_sent).toBe(1); + await threadsRequest(harness, s.owner.token) + .delete(`/channels/${thread.id}/messages/${thread.id}`) + .expect(404, APIErrorCodes.UNKNOWN_MESSAGE) + .execute(); + await threadsRequest(harness, s.owner.token).patch(`/channels/${thread.id}`).body({name: 'renamed'}).execute(); + const renamed = await getThread(s.member.token, thread.id); + expect(renamed.message_count).toBe(1); + expect(renamed.total_message_sent).toBe(2); + }); + + it('returns thread channels for bulk fetch viewers only', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await send(s.owner.token, thread.id, 'bulk'); + const body = {requests: [{channel_id: thread.id, limit: 10}]}; + const viewer = await threadsRequest<{channels: Array<{messages: Array}>}>(harness, s.owner.token) + .post('/channels/messages/bulk') + .body(body) + .execute(); + expect(viewer.channels[0]?.messages.length).toBeGreaterThan(0); + await threadsRequest(harness, s.owner.token, {capable: false}) + .post('/channels/messages/bulk') + .body(body) + .expect(404, APIErrorCodes.UNKNOWN_CHANNEL) + .execute(); + }); + }); + + describe('modify and delete', () => { + it('follows the thread permission rules on PATCH', async () => { + const s = await setup(); + const thread = await startThread(s.member.token, s.channelId); + const renamed = await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}`) + .body({name: 'renamed', auto_archive_duration: 60}) + .execute(); + expect(renamed).toMatchObject({name: 'renamed', thread_metadata: {auto_archive_duration: 60}}); + expect(Date.parse(renamed.thread_metadata!.archive_timestamp)).toBeGreaterThan( + Date.parse(thread.thread_metadata!.archive_timestamp), + ); + await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}`) + .body({rate_limit_per_user: 5}) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}`) + .body({flags: 1 << 4}) + .expect(400) + .execute(); + const locked = await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}`) + .body({locked: true, archived: true}) + .execute(); + expect(locked.thread_metadata).toMatchObject({locked: true, archived: true}); + await threadsRequest(harness, s.member.token) + .patch(`/channels/${thread.id}`) + .body({archived: false}) + .expect(400, APIErrorCodes.THREAD_LOCKED) + .execute(); + const reopened = await threadsRequest(harness, s.owner.token) + .patch(`/channels/${thread.id}`) + .body({archived: false, locked: false}) + .execute(); + expect(reopened.thread_metadata).toMatchObject({locked: false, archived: false}); + }); + + it('deletes a thread for moderators only and records audit entries for viewers', async () => { + const s = await setup(); + const thread = await startThread(s.member.token, s.channelId); + await threadsRequest(harness, s.member.token).patch(`/channels/${thread.id}`).body({name: 'audited'}).execute(); + await threadsRequest(harness, s.member.token) + .delete(`/channels/${thread.id}`) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await threadsRequest(harness, s.owner.token).delete(`/channels/${thread.id}`).expect(204).execute(); + const deleted = dispatch.mock.calls.find(([params]) => params.event === 'THREAD_DELETE')?.[0]; + expect(deleted?.data).toMatchObject({_fluxer_member_ids: [s.member.userId]}); + expect(await threadState(thread.id)).toBeNull(); + await threadsRequest(harness, s.owner.token).get(`/channels/${thread.id}`).expect(404).execute(); + const viewerLog = await threadsRequest(harness, s.owner.token) + .get(`/guilds/${s.guildId}/audit-logs`) + .execute(); + const actions = viewerLog.audit_log_entries.map((entry) => entry.action_type); + expect(actions).toContain(AuditLogActionType.THREAD_UPDATE); + expect(actions).toContain(AuditLogActionType.THREAD_DELETE); + expect(viewerLog.threads).toBeDefined(); + const controlLog = await threadsRequest(harness, s.owner.token, {capable: false}) + .get(`/guilds/${s.guildId}/audit-logs`) + .execute(); + expect(controlLog.audit_log_entries.map((entry) => entry.action_type)).not.toContain( + AuditLogActionType.THREAD_UPDATE, + ); + expect(controlLog).not.toHaveProperty('threads'); + await threadsRequest(harness, s.owner.token, {capable: false}) + .get(`/guilds/${s.guildId}/audit-logs?action_type=110`) + .expect(400) + .execute(); + await threadsRequest(harness, s.owner.token).get(`/guilds/${s.guildId}/audit-logs?action_type=110`).execute(); + }); + + it('tags batched thread message deletes and never merges parent deletes across hidden entries', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const remove = (channelId: string, messageId: string) => + threadsRequest(harness, s.owner.token) + .delete(`/channels/${channelId}/messages/${messageId}`) + .expect(204) + .execute(); + const t1 = await send(s.member.token, thread.id, 't1'); + const t2 = await send(s.member.token, thread.id, 't2'); + await remove(thread.id, t1.id); + await remove(thread.id, t2.id); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await threadsRequest(harness, s.owner.token).get(`/guilds/${s.guildId}/audit-logs`).execute(); + const bulk = dispatch.mock.calls + .map(([params]) => params) + .find( + (params) => + params.event === 'GUILD_AUDIT_LOG_ENTRY_CREATE' && + (params.data as {action_type: number}).action_type === AuditLogActionType.MESSAGE_BULK_DELETE, + ); + expect(bulk?.data).toMatchObject({__thread_scoped: s.guildId, options: {channel_id: thread.id}}); + const p1 = await sendMessage(harness, s.member.token, s.channelId, 'p1'); + const p2 = await sendMessage(harness, s.member.token, s.channelId, 'p2'); + const t3 = await send(s.member.token, thread.id, 't3'); + await remove(s.channelId, p1.id); + await remove(thread.id, t3.id); + await remove(s.channelId, p2.id); + const controlLog = await threadsRequest(harness, s.owner.token, {capable: false}) + .get(`/guilds/${s.guildId}/audit-logs`) + .execute(); + const parentDeletes = controlLog.audit_log_entries.filter( + (entry) => + entry.options?.channel_id === s.channelId && + (entry.action_type === AuditLogActionType.MESSAGE_DELETE || + entry.action_type === AuditLogActionType.MESSAGE_BULK_DELETE), + ); + expect(parentDeletes.map((entry) => entry.action_type)).toEqual([ + AuditLogActionType.MESSAGE_DELETE, + AuditLogActionType.MESSAGE_DELETE, + ]); + }); + + it('drops the guild auto-archive queue when the guild is deleted', async () => { + const s = await setup(); + await startThread(s.owner.token, s.channelId); + const queue = getKVThreadAutoArchiveQueue(); + const guildId = createGuildID(BigInt(s.guildId)); + expect(await queue.listGuilds()).toContain(guildId); + await createBuilder(harness, s.owner.token) + .post(`/guilds/${s.guildId}/delete`) + .body({password: s.owner.password}) + .expect(204) + .execute(); + expect(await queue.listGuilds()).not.toContain(guildId); + expect(await queue.getDue(guildId, Number.MAX_SAFE_INTEGER, 10)).toEqual([]); + }); + + it('keeps message entries of a deleted thread hidden from non-viewers', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const single = await send(s.member.token, thread.id, 'single'); + await threadsRequest(harness, s.owner.token) + .delete(`/channels/${thread.id}/messages/${single.id}`) + .expect(204) + .execute(); + const t1 = await send(s.member.token, thread.id, 't1'); + const t2 = await send(s.member.token, thread.id, 't2'); + const other = await createChannel(harness, s.owner.token, s.guildId, 'spacer'); + const spacer = await sendMessage(harness, s.member.token, other.id, 'spacer'); + for (const [channelId, messageId] of [ + [other.id, spacer.id], + [thread.id, t1.id], + [thread.id, t2.id], + ]) { + await threadsRequest(harness, s.owner.token) + .delete(`/channels/${channelId}/messages/${messageId}`) + .expect(204) + .execute(); + } + await threadsRequest(harness, s.owner.token).delete(`/channels/${thread.id}`).expect(204).execute(); + const viewerLog = await threadsRequest(harness, s.owner.token) + .get(`/guilds/${s.guildId}/audit-logs`) + .execute(); + const threadEntries = (log: GuildAuditLogListResponse) => + log.audit_log_entries.filter((entry) => entry.options?.channel_id === thread.id); + expect(threadEntries(viewerLog).map((entry) => entry.action_type)).toEqual( + expect.arrayContaining([AuditLogActionType.MESSAGE_DELETE, AuditLogActionType.MESSAGE_BULK_DELETE]), + ); + const controlLog = await threadsRequest(harness, s.owner.token, {capable: false}) + .get(`/guilds/${s.guildId}/audit-logs`) + .execute(); + expect(threadEntries(controlLog)).toEqual([]); + expect(controlLog.audit_log_entries.some((entry) => entry.options?.channel_id === other.id)).toBe(true); + }); + + it('refuses invites and overwrites on threads', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${thread.id}/invites`) + .body({}) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${thread.id}/permissions/${s.member.userId}`) + .body({type: 1, allow: '0', deny: Permissions.SEND_MESSAGES.toString()}) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + await threadsRequest(harness, s.owner.token, {capable: false}) + .put(`/channels/${thread.id}/permissions/${s.member.userId}`) + .body({type: 1, allow: '0', deny: Permissions.SEND_MESSAGES.toString()}) + .expect(404, APIErrorCodes.UNKNOWN_CHANNEL) + .execute(); + }); + }); + + describe('webhooks', () => { + it('posts into a child thread and refuses foreign threads', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const other = await createChannel(harness, s.owner.token, s.guildId, 'other'); + const otherThread = await startThread(s.owner.token, other.id); + const webhook = await createWebhook(harness, s.channelId, s.owner.token, 'hook'); + await repository.threads.updateState(createChannelID(BigInt(thread.id)), () => ({archived: true})); + const posted = await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}?wait=true&thread_id=${thread.id}`) + .body({content: 'into thread'}) + .expect(200) + .execute(); + expect(posted.channel_id).toBe(thread.id); + expect((await threadState(thread.id))?.archived).toBe(false); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}?thread_id=${otherThread.id}`) + .body({content: 'wrong parent'}) + .expect(404, APIErrorCodes.UNKNOWN_CHANNEL) + .execute(); + const fetched = await createBuilderWithoutAuth(harness) + .get(`/webhooks/${webhook.id}/${webhook.token}/messages/${posted.id}?thread_id=${thread.id}`) + .execute(); + expect(fetched.id).toBe(posted.id); + }); + + it('refuses locked threads and edits archived or locked messages but deletes with thread_id', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const webhook = await createWebhook(harness, s.channelId, s.owner.token, 'hook'); + const hookPath = `/webhooks/${webhook.id}/${webhook.token}`; + const threadKey = createChannelID(BigInt(thread.id)); + const posted = await createBuilderWithoutAuth(harness) + .post(`${hookPath}?wait=true&thread_id=${thread.id}`) + .body({content: 'first'}) + .expect(200) + .execute(); + const edited = await createBuilderWithoutAuth(harness) + .patch(`${hookPath}/messages/${posted.id}?thread_id=${thread.id}`) + .body({content: 'edited'}) + .expect(200) + .execute(); + expect(edited.content).toBe('edited'); + expect(edited.channel_id).toBe(thread.id); + await createBuilderWithoutAuth(harness) + .patch(`${hookPath}/messages/${posted.id}`) + .body({content: 'parent lookup'}) + .expect(404, APIErrorCodes.UNKNOWN_MESSAGE) + .execute(); + await repository.threads.updateState(threadKey, () => ({archived: true})); + await createBuilderWithoutAuth(harness) + .patch(`${hookPath}/messages/${posted.id}?thread_id=${thread.id}`) + .body({content: 'archived'}) + .expect(400, APIErrorCodes.THREAD_ARCHIVED) + .execute(); + await repository.threads.updateState(threadKey, () => ({archived: false, locked: true})); + await createBuilderWithoutAuth(harness) + .patch(`${hookPath}/messages/${posted.id}?thread_id=${thread.id}`) + .body({content: 'locked'}) + .expect(400, APIErrorCodes.THREAD_LOCKED) + .execute(); + await createBuilderWithoutAuth(harness) + .post(`${hookPath}?thread_id=${thread.id}`) + .body({content: 'locked out'}) + .expect(400, APIErrorCodes.THREAD_LOCKED) + .execute(); + expect((await getThread(s.owner.token, thread.id)).message_count).toBe(1); + await createBuilderWithoutAuth(harness) + .delete(`${hookPath}/messages/${posted.id}?thread_id=${thread.id}`) + .expect(204) + .execute(); + expect((await getThread(s.owner.token, thread.id)).message_count).toBe(0); + await createBuilderWithoutAuth(harness) + .get(`${hookPath}/messages/${posted.id}?thread_id=${thread.id}`) + .expect(404, APIErrorCodes.UNKNOWN_MESSAGE) + .execute(); + }); + + it('refuses thread_id on message lookups, edits and deletes in a tainted inactive guild', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const webhook = await createWebhook(harness, s.channelId, s.owner.token, 'hook'); + const hookPath = `/webhooks/${webhook.id}/${webhook.token}`; + const posted = await createBuilderWithoutAuth(harness) + .post(`${hookPath}?wait=true&thread_id=${thread.id}`) + .body({content: 'kept'}) + .expect(200) + .execute(); + await setChannelThreadsConfig({enabled: false}); + const messagePath = `${hookPath}/messages/${posted.id}?thread_id=${thread.id}`; + await createBuilderWithoutAuth(harness).get(messagePath).expect(404, APIErrorCodes.UNKNOWN_CHANNEL).execute(); + await createBuilderWithoutAuth(harness) + .patch(messagePath) + .body({content: 'refused'}) + .expect(404, APIErrorCodes.UNKNOWN_CHANNEL) + .execute(); + await createBuilderWithoutAuth(harness).delete(messagePath).expect(404, APIErrorCodes.UNKNOWN_CHANNEL).execute(); + const message = await repository.messages.getMessage( + createChannelID(BigInt(thread.id)), + createMessageID(BigInt(posted.id)), + ); + expect(message?.content).toBe('kept'); + }); + + it('strips thread_id in never-enabled instances and refuses it once the guild is tainted', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'control'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const webhook = await createWebhook(harness, channel.id, owner.token, 'hook'); + const posted = await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}?wait=true&thread_id=123`) + .body({content: 'parent'}) + .expect(200) + .execute(); + expect(posted.channel_id).toBe(channel.id); + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + await startThread(owner.token, channel.id); + await setChannelThreadsConfig({enabled: false}); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}?thread_id=123`) + .body({content: 'refused'}) + .expect(404, APIErrorCodes.UNKNOWN_CHANNEL) + .execute(); + }); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/ThreadNsfw.test.ts b/fluxer_api/src/api/channel/tests/ThreadNsfw.test.ts new file mode 100644 index 000000000..05045d23e --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ThreadNsfw.test.ts @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createReportID} from '@app/api/BrandedTypes'; +import {MessageContentService} from '@app/api/channel/services/message/MessageContentService'; +import {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService'; +import {MessageSendService} from '@app/api/channel/services/message/MessageSendService'; +import { + acceptInvite, + createChannel, + createChannelInvite, + createGuild, + updateChannel, +} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {EmbedService} from '@app/api/infrastructure/EmbedService'; +import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {ReportRepository} from '@app/api/report/ReportRepository'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type {StartForumThreadResponse} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface Setup { + owner: TestAccount; + member: TestAccount; + guildId: string; + sfwChannelId: string; + nsfwChannelId: string; +} + +describe('thread nsfw scope', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'nsfw'); + const sfw = await createChannel(harness, owner.token, guild.id, 'sfw'); + const nsfw = await createChannel(harness, owner.token, guild.id, 'nsfw'); + await updateChannel(harness, owner.token, nsfw.id, {nsfw: true}); + const invite = await createChannelInvite(harness, owner.token, sfw.id); + await acceptInvite(harness, member.token, invite.code); + await ensureSessionStarted(harness, owner.token); + await ensureSessionStarted(harness, member.token); + return {owner, member, guildId: guild.id, sfwChannelId: sfw.id, nsfwChannelId: nsfw.id}; + } + + function startThread(token: string, channelId: string) { + return threadsRequest(harness, token) + .post(`/channels/${channelId}/threads`) + .body({name: 'topic', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + } + + function post(token: string, channelId: string, body: Record, status = 200, code?: string) { + return threadsRequest(harness, token) + .post(`/channels/${channelId}/messages`) + .body(body) + .expect(status, code) + .execute(); + } + + it('allows nsfw attachments and unfurls in a thread under an nsfw parent', async () => { + const s = await setup(); + const nsfwThread = await startThread(s.owner.token, s.nsfwChannelId); + const sfwThread = await startThread(s.owner.token, s.sfwChannelId); + const attachments = vi.spyOn( + MessagePersistenceService.prototype as unknown as {processAttachments: (...args: Array) => unknown}, + 'processAttachments', + ); + const embeds = vi.spyOn(EmbedService.prototype, 'getInitialEmbeds'); + const scope = vi.spyOn(MessageContentService.prototype, 'isNSFWContentAllowed'); + await post(s.owner.token, nsfwThread.id, {content: 'see https://example.com/a'}); + expect(scope.mock.calls.at(-1)?.[0].channel?.id.toString()).toBe(s.nsfwChannelId); + expect(scope.mock.results.at(-1)?.value).toBe(true); + expect(attachments.mock.calls.at(-1)?.[1]).toBe('allow'); + expect(embeds.mock.calls.at(-1)?.[0].nsfwMode).toBe('allow'); + await post(s.owner.token, sfwThread.id, {content: 'see https://example.com/b'}); + expect(scope.mock.calls.at(-1)?.[0].channel?.id.toString()).toBe(s.sfwChannelId); + expect(scope.mock.calls.at(-1)?.[0].channel?.isNsfw).toBe(false); + }); + + it('accepts nsfw forwards into a thread only under an nsfw parent', async () => { + const s = await setup(); + const nsfwThread = await startThread(s.owner.token, s.nsfwChannelId); + const sfwThread = await startThread(s.owner.token, s.sfwChannelId); + const source = await sendMessage(harness, s.owner.token, s.sfwChannelId, 'forward me'); + vi.spyOn( + MessageSendService.prototype as unknown as {snapshotsContainNsfwContent: () => boolean}, + 'snapshotsContainNsfwContent', + ).mockReturnValue(true); + const reference = {type: 1, channel_id: s.sfwChannelId, message_id: source.id}; + await post(s.owner.token, nsfwThread.id, {message_reference: reference}); + await post( + s.owner.token, + sfwThread.id, + {message_reference: reference}, + 403, + APIErrorCodes.NSFW_CONTENT_AGE_RESTRICTED, + ); + }); + + it('requires age verification for posts and post data under an nsfw forum', async () => { + const s = await setup(); + const minor = await createTestAccount(harness, {dateOfBirth: '2012-01-01'}); + const invite = await createChannelInvite(harness, s.owner.token, s.sfwChannelId); + await acceptInvite(harness, minor.token, invite.code); + await ensureSessionStarted(harness, minor.token); + const forum = await threadsRequest(harness, s.owner.token) + .post(`/guilds/${s.guildId}/channels`) + .body({name: 'forum', type: ChannelTypes.GUILD_FORUM, nsfw: true}) + .execute(); + const created = await threadsRequest(harness, s.owner.token) + .post(`/channels/${forum.id}/threads`) + .body({name: 'post', message: {content: 'hello'}}) + .expect(201) + .execute(); + const restricted = (method: 'get' | 'post', route: string, body?: Record) => { + const request = threadsRequest(harness, minor.token)[method](route); + return (body ? request.body(body) : request).expect(403, APIErrorCodes.NSFW_CONTENT_AGE_RESTRICTED).execute(); + }; + await restricted('get', `/channels/${forum.id}`); + await restricted('get', `/channels/${created.id}/messages`); + await restricted('post', `/channels/${forum.id}/post-data`, {thread_ids: [created.id]}); + await restricted('get', `/channels/${forum.id}/threads/search`); + await threadsRequest(harness, s.member.token).get(`/channels/${created.id}/messages`).expect(200).execute(); + }); + + it('records the effective nsfw of the parent on a thread message report', async () => { + const s = await setup(); + const invite = await createChannelInvite(harness, s.owner.token, s.nsfwChannelId); + await acceptInvite(harness, s.member.token, invite.code); + const thread = await startThread(s.owner.token, s.nsfwChannelId); + const message = await post(s.owner.token, thread.id, {content: 'reported'}); + const report = await threadsRequest<{report_id: string}>(harness, s.member.token) + .post('/reports/message') + .body({channel_id: thread.id, message_id: message.id, category: 'harassment'}) + .expect(200) + .execute(); + const stored = await new ReportRepository().getReport(createReportID(BigInt(report.report_id))); + expect(stored?.reportedChannelEffectiveNsfw).toBe(true); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/ThreadPinsAck.test.ts b/fluxer_api/src/api/channel/tests/ThreadPinsAck.test.ts new file mode 100644 index 000000000..4a021097a --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ThreadPinsAck.test.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createUserID} from '@app/api/BrandedTypes'; +import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {getReadStateRepository} from '@app/api/middleware/ServiceSingletons'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ReadStateFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface Setup { + owner: TestAccount; + member: TestAccount; + outsider: TestAccount; + threadId: string; +} + +describe('thread pins ack', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const outsider = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'pins'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, channel.id); + await acceptInvite(harness, member.token, invite.code); + const thread = await threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'private', type: ChannelTypes.PRIVATE_THREAD}) + .expect(201) + .execute(); + const message = await threadsRequest(harness, owner.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'pin me'}) + .expect(200) + .execute(); + await threadsRequest(harness, owner.token).put(`/channels/${thread.id}/pins/${message.id}`).expect(204).execute(); + return {owner, member, outsider, threadId: thread.id}; + } + + async function pinAckRow(account: TestAccount, threadId: string) { + const row = await getReadStateRepository().getReadState( + createUserID(BigInt(account.userId)), + createChannelID(BigInt(threadId)), + ); + return row?.lastPinTimestamp ? row : null; + } + + function pinsAckDispatches(dispatch: {mock: {calls: Array>}}) { + return dispatch.mock.calls.filter(([params]) => (params as {event: string}).event === 'CHANNEL_PINS_ACK'); + } + + it('stamps the ack for a thread member', async () => { + const s = await setup(); + await threadsRequest(harness, s.owner.token).post(`/channels/${s.threadId}/pins/ack`).expect(204).execute(); + const row = await pinAckRow(s.owner, s.threadId); + expect(row).not.toBeNull(); + expect(row?.flags).toBe(ReadStateFlags.IS_GUILD_CHANNEL | ReadStateFlags.IS_THREAD); + }); + + it('treats a thread as an unknown channel for non-viewers', async () => { + const s = await setup(); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence'); + for (const account of [s.member, s.outsider]) { + await threadsRequest(harness, account.token, {capable: false}) + .post(`/channels/${s.threadId}/pins/ack`) + .expect(204) + .execute(); + expect(await pinAckRow(account, s.threadId)).toBeNull(); + } + await setChannelThreadsConfig({...ALL_THREADS_ACTIVE, enabled: false}); + await threadsRequest(harness, s.owner.token).post(`/channels/${s.threadId}/pins/ack`).expect(204).execute(); + expect(await pinAckRow(s.owner, s.threadId)).toBeNull(); + expect(pinsAckDispatches(dispatch)).toEqual([]); + }); + + it('refuses viewers who cannot see the private thread', async () => { + const s = await setup(); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence'); + await threadsRequest(harness, s.member.token).post(`/channels/${s.threadId}/pins/ack`).expect(403).execute(); + await threadsRequest(harness, s.outsider.token).post(`/channels/${s.threadId}/pins/ack`).expect(403).execute(); + expect(await pinAckRow(s.member, s.threadId)).toBeNull(); + expect(await pinAckRow(s.outsider, s.threadId)).toBeNull(); + expect(pinsAckDispatches(dispatch)).toEqual([]); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/ThreadRoutes.test.ts b/fluxer_api/src/api/channel/tests/ThreadRoutes.test.ts new file mode 100644 index 000000000..8b762bfca --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ThreadRoutes.test.ts @@ -0,0 +1,992 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes'; +import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository'; +import { + acceptInvite, + addMemberRole, + createChannel, + createChannelInvite, + createGuild, +} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + THREADS_FEATURE, + THREADS_FEATURE_HEADER, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes, MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {ServerMessageFlags, ThreadMemberFlags} from '@fluxer/constants/src/ThreadConstants'; +import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {ThreadAlreadyCreatedForMessageError} from '@fluxer/errors/src/domains/channel/ThreadAlreadyCreatedForMessageError'; +import type { + ActiveThreadsResponse, + ArchivedThreadsResponse, + ThreadChannelResponse, +} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; +import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {RateLimitService} from '@pkgs/rate_limit/src/RateLimitService'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface Setup { + owner: TestAccount; + member: TestAccount; + guildId: string; + channelId: string; +} + +const repository = new ChannelRepository(); + +async function archive(threadId: string, archiveTimestamp?: Date): Promise { + await repository.threads.updateState(createChannelID(BigInt(threadId)), () => ({ + archived: true, + ...(archiveTimestamp ? {archive_timestamp: archiveTimestamp} : {}), + })); +} + +async function lock(threadId: string): Promise { + await repository.threads.updateState(createChannelID(BigInt(threadId)), () => ({locked: true})); +} + +describe('thread routes', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'threads'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, channel.id); + await acceptInvite(harness, member.token, invite.code); + return {owner, member, guildId: guild.id, channelId: channel.id}; + } + + async function addBot( + s: Setup, + permissions = Permissions.ADMINISTRATOR, + ): Promise>> { + const bot = await createTestBotAccount(harness); + await threadsRequest(harness, s.owner.token) + .post('/oauth2/authorize/consent') + .body({client_id: bot.appId, scope: 'bot', guild_id: s.guildId, permissions: permissions.toString()}) + .execute(); + return bot; + } + + async function startThread( + token: string, + channelId: string, + body: Record = {name: 'topic', type: ChannelTypes.PUBLIC_THREAD}, + ): Promise { + return threadsRequest(harness, token) + .post(`/channels/${channelId}/threads`) + .body(body) + .expect(201) + .execute(); + } + + describe('control arm', () => { + async function snapshot(path: string, method: string, token: string, headers: Record) { + const response = await harness.requestJson({ + path, + method, + headers: {Authorization: token, 'x-fluxer-test-enable-rate-limits': 'true', ...headers}, + body: method === 'GET' || method === 'DELETE' || method === 'PUT' ? undefined : {name: 'x', type: 11}, + }); + return { + status: response.status, + body: await response.text(), + headers: [...response.headers].filter(([name]) => name !== 'x-request-id'), + }; + } + + function routes(s: Setup): Array<[string, string]> { + const c = s.channelId; + return [ + ['POST', `/channels/${c}/messages/${c}/threads`], + ['POST', `/channels/${c}/threads`], + ['GET', `/guilds/${s.guildId}/threads/active`], + ['GET', `/channels/${c}/threads/archived/public`], + ['GET', `/channels/${c}/threads/archived/private`], + ['GET', `/channels/${c}/users/@me/threads/archived/private`], + ['GET', `/channels/${c}/thread-members`], + ['GET', `/channels/${c}/thread-members/${s.member.userId}`], + ['PUT', `/channels/${c}/thread-members/@me`], + ['PUT', `/channels/${c}/thread-members/${s.member.userId}`], + ['DELETE', `/channels/${c}/thread-members/@me`], + ['DELETE', `/channels/${c}/thread-members/${s.member.userId}`], + ]; + } + + async function expectEveryRouteUnregistered(s: Setup, token: string, headers: Record) { + for (const [method, path] of routes(s)) { + const unregistered = path.startsWith('/guilds/') + ? `/guilds/${s.guildId}/threads-unregistered` + : `/channels/${s.channelId}/threads-unregistered`; + const expected = await snapshot(unregistered, method, token, headers); + const actual = await snapshot(path, method, token, headers); + expect(actual.status, `${method} ${path}`).toBe(404); + expect(actual, `${method} ${path}`).toEqual(expected); + expect(actual.headers.some(([name]) => name.startsWith('x-ratelimit'))).toBe(false); + } + } + + it('answers every thread route like an unregistered path while the experiment is off', async () => { + const s = await setup(); + await setChannelThreadsConfig({enabled: false}); + await expectEveryRouteUnregistered(s, s.owner.token, {[THREADS_FEATURE_HEADER]: THREADS_FEATURE}); + }); + + it('answers every thread route like an unregistered path without the client capability', async () => { + const s = await setup(); + await expectEveryRouteUnregistered(s, s.owner.token, {}); + }); + + it('answers every thread route like an unregistered path for a guild outside the experiment', async () => { + const s = await setup(); + await setChannelThreadsConfig({enabled: true, user_basis_points: 10000, disabled_guild_ids: [s.guildId]}); + await expectEveryRouteUnregistered(s, s.owner.token, {[THREADS_FEATURE_HEADER]: THREADS_FEATURE}); + }); + + it('answers bot-only routes like an unregistered path for users', async () => { + const s = await setup(); + const headers = {Authorization: s.owner.token, [THREADS_FEATURE_HEADER]: THREADS_FEATURE}; + for (const path of [ + `/guilds/${s.guildId}/threads/active`, + `/channels/${s.channelId}/thread-members`, + `/channels/${s.channelId}/thread-members/${s.member.userId}`, + ]) { + const response = await harness.requestJson({path, method: 'GET', headers}); + expect(response.status).toBe(404); + expect(((await response.json()) as {code: string}).code).toBe('NOT_FOUND'); + } + }); + + it('writes no thread data and dispatches nothing when the gate is off', async () => { + const s = await setup(); + await setChannelThreadsConfig({enabled: false}); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const message = await sendMessage(harness, s.owner.token, s.channelId, 'hello'); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: 'nope'}) + .expect(404) + .execute(); + expect(await repository.threads.getState(createChannelID(BigInt(message.id)))).toBeNull(); + expect(dispatch.mock.calls.filter(([params]) => params.event.startsWith('THREAD_'))).toEqual([]); + }); + }); + + describe('start thread from message', () => { + it('creates a public thread that shares the message id', async () => { + const s = await setup(); + const message = await sendMessage(harness, s.member.token, s.channelId, 'start here'); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const thread = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: 'Discussion', auto_archive_duration: 1440}) + .expect(201) + .execute(); + expect(thread).toMatchObject({ + id: message.id, + type: ChannelTypes.PUBLIC_THREAD, + guild_id: s.guildId, + parent_id: s.channelId, + owner_id: s.owner.userId, + name: 'Discussion', + member_count: 1, + message_count: 0, + total_message_sent: 0, + rate_limit_per_user: 0, + flags: 0, + }); + expect(thread.thread_metadata).toMatchObject({archived: false, locked: false, auto_archive_duration: 1440}); + expect(thread.thread_metadata?.invitable).toBeUndefined(); + expect(thread.member).toBeUndefined(); + expect('position' in thread).toBe(false); + expect('permission_overwrites' in thread).toBe(false); + const threadEvents = dispatch.mock.calls + .map(([params]) => params) + .filter((params) => params.event !== 'GUILD_AUDIT_LOG_ENTRY_CREATE'); + expect(threadEvents.map((params) => params.event)).toEqual(['THREAD_CREATE', 'MESSAGE_UPDATE', 'MESSAGE_CREATE']); + expect(threadEvents[0]!.data).toMatchObject({id: message.id, newly_created: true}); + const starter = threadEvents[2]!.data as MessageResponse; + expect(starter).toMatchObject({ + id: message.id, + channel_id: message.id, + type: MessageTypes.THREAD_STARTER_MESSAGE, + message_reference: {channel_id: s.channelId, message_id: message.id}, + }); + expect(starter.referenced_message?.id).toBe(message.id); + expect(starter.referenced_message?.thread?.id).toBe(message.id); + expect(starter.referenced_message?.thread && 'member' in starter.referenced_message.thread).toBe(false); + const update = threadEvents[1]!.data as MessageResponse & {__thread_only_update?: boolean}; + expect(update.flags & ServerMessageFlags.HAS_THREAD).toBe(ServerMessageFlags.HAS_THREAD); + expect(update.__thread_only_update).toBe(true); + expect(update.thread?.id).toBe(message.id); + const stored = await repository.messages.getMessage( + createChannelID(BigInt(s.channelId)), + createMessageID(BigInt(message.id)), + ); + expect((stored?.flags ?? 0) & ServerMessageFlags.HAS_THREAD).toBe(ServerMessageFlags.HAS_THREAD); + const member = await repository.threads.getMember( + createChannelID(BigInt(message.id)), + createUserID(BigInt(s.owner.userId)), + ); + expect(member?.flags).toBe(ThreadMemberFlags.HAS_INTERACTED); + }); + + it('announces threads started from older messages in the parent without moving its last message', async () => { + const s = await setup(); + const message = await sendMessage(harness, s.owner.token, s.channelId, 'old news'); + const now = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(now + 6 * 60 * 1000); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: 'Later'}) + .expect(201) + .execute(); + const announced = dispatch.mock.calls + .map(([params]) => params.data as MessageResponse) + .filter((data) => data.type === MessageTypes.THREAD_CREATED); + expect(announced).toHaveLength(1); + expect(announced[0]).toMatchObject({channel_id: s.channelId, content: 'Later'}); + expect(announced[0]!.message_reference?.channel_id).toBe(message.id); + const parent = await repository.findUnique(createChannelID(BigInt(s.channelId))); + expect(parent?.lastMessageId?.toString()).toBe(message.id); + }); + + it('refuses a second thread on the same message', async () => { + const s = await setup(); + const message = await sendMessage(harness, s.owner.token, s.channelId, 'once'); + const path = `/channels/${s.channelId}/messages/${message.id}/threads`; + await threadsRequest(harness, s.owner.token).post(path).body({name: 'a'}).expect(201).execute(); + await threadsRequest(harness, s.owner.token) + .post(path) + .body({name: 'b'}) + .expect(400, APIErrorCodes.THREAD_ALREADY_CREATED_FOR_MESSAGE) + .execute(); + }); + + it('rejects unknown messages, bad names and non-text parents', async () => { + const s = await setup(); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/messages/1/threads`) + .body({name: 'a'}) + .expect(404, APIErrorCodes.UNKNOWN_MESSAGE) + .execute(); + const message = await sendMessage(harness, s.owner.token, s.channelId, 'x'); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: ''}) + .expect(400) + .execute(); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${thread.id}/messages/${message.id}/threads`) + .body({name: 'nested'}) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + }); + + it('requires create public threads and read message history on the parent', async () => { + const s = await setup(); + const message = await sendMessage(harness, s.owner.token, s.channelId, 'x'); + await createBuilder(harness, s.owner.token) + .patch(`/guilds/${s.guildId}/roles/${s.guildId}`) + .header(THREADS_FEATURE_HEADER, THREADS_FEATURE) + .body({permissions: (Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES).toString()}) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: 'denied'}) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + }); + + it('spends the parent slowmode only on a thread that gets created', async () => { + const s = await setup(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.channelId}`) + .body({rate_limit_per_user: 60}) + .expect(200) + .execute(); + const message = await sendMessage(harness, s.owner.token, s.channelId, 'x'); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages/1/threads`) + .body({name: 'missing'}) + .expect(404, APIErrorCodes.UNKNOWN_MESSAGE) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: 'first'}) + .expect(201) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: 'again'}) + .expect(400, APIErrorCodes.THREAD_ALREADY_CREATED_FOR_MESSAGE) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/threads`) + .body({name: 'second', type: ChannelTypes.PUBLIC_THREAD}) + .expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED) + .execute(); + }); + + it('refunds the parent slowmode when the thread write loses a race', async () => { + const s = await setup(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.channelId}`) + .body({rate_limit_per_user: 60}) + .expect(200) + .execute(); + const message = await sendMessage(harness, s.owner.token, s.channelId, 'x'); + vi.spyOn(ThreadRepository.prototype, 'create').mockRejectedValueOnce(new ThreadAlreadyCreatedForMessageError()); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: 'raced'}) + .expect(400, APIErrorCodes.THREAD_ALREADY_CREATED_FOR_MESSAGE) + .execute(); + await startThread(s.member.token, s.channelId, {name: 'next', type: ChannelTypes.PUBLIC_THREAD}); + }); + + it('resets the thread create slowmode when the parent rate limit changes', async () => { + const s = await setup(); + const setRateLimit = (seconds: number) => + threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.channelId}`) + .body({rate_limit_per_user: seconds}) + .expect(200) + .execute(); + await setRateLimit(60); + await startThread(s.member.token, s.channelId, {name: 'first', type: ChannelTypes.PUBLIC_THREAD}); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/threads`) + .body({name: 'blocked', type: ChannelTypes.PUBLIC_THREAD}) + .expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED) + .execute(); + const clear = vi.spyOn(RateLimitService.prototype, 'clearLimitsByIdentifierPrefix'); + await setRateLimit(30); + expect(clear.mock.calls).toEqual([[`slowmode:${s.channelId}:`], [`slowmode-thread:${s.channelId}:`]]); + await startThread(s.member.token, s.channelId, {name: 'second', type: ChannelTypes.PUBLIC_THREAD}); + }); + + it('clears only the message slowmode on a rate limit change in a guild without threads', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'control'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const clear = vi.spyOn(RateLimitService.prototype, 'clearLimitsByIdentifierPrefix'); + const arms: Array<[Parameters[0], number]> = [ + [{enabled: false}, 60], + [{enabled: true, included_user_ids: [owner.userId]}, 30], + ]; + for (const [config, seconds] of arms) { + await setChannelThreadsConfig(config); + clear.mockClear(); + await threadsRequest(harness, owner.token) + .patch(`/channels/${channel.id}`) + .body({rate_limit_per_user: seconds}) + .expect(200) + .execute(); + expect(clear.mock.calls).toEqual([[`slowmode:${channel.id}:`]]); + } + }); + }); + + it('refuses a limited account on both thread create routes without dispatching', async () => { + const s = await setup(); + const message = await sendMessage(harness, s.owner.token, s.channelId, 'start here'); + await createBuilder(harness, '') + .post(`/test/users/${s.member.userId}/security-flags`) + .body({set_flags: ['ACCOUNT_LIMITED']}) + .execute(); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/threads`) + .body({name: 'limited', type: ChannelTypes.PUBLIC_THREAD}) + .expect(403, APIErrorCodes.ACCOUNT_LIMITED) + .execute(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages/${message.id}/threads`) + .body({name: 'limited'}) + .expect(403, APIErrorCodes.ACCOUNT_LIMITED) + .execute(); + expect(await repository.threads.getState(createChannelID(BigInt(message.id)))).toBeNull(); + expect(dispatch.mock.calls.filter(([params]) => params.event.startsWith('THREAD_'))).toEqual([]); + }); + + describe('start thread without a message', () => { + it('requires a thread type', async () => { + const s = await setup(); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/threads`) + .body({name: 'no type'}) + .expect(400) + .execute(); + }); + + it('accepts the body as payload_json in multipart form data', async () => { + const s = await setup(); + const multipart = (payload: string) => { + const boundary = 'ThreadBoundary'; + return harness.app.request(`/channels/${s.channelId}/threads`, { + method: 'POST', + headers: { + Authorization: s.owner.token, + [THREADS_FEATURE_HEADER]: THREADS_FEATURE, + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'x-forwarded-for': '127.0.0.1', + }, + body: `--${boundary}\r\nContent-Disposition: form-data; name="payload_json"\r\n\r\n${payload}\r\n--${boundary}--\r\n`, + }); + }; + const created = await multipart(JSON.stringify({name: 'form', type: ChannelTypes.PRIVATE_THREAD})); + expect(created.status).toBe(201); + const thread = (await created.json()) as ThreadChannelResponse; + expect(thread.type).toBe(ChannelTypes.PRIVATE_THREAD); + expect(thread.name).toBe('form'); + expect((await multipart('{not json')).status).toBe(400); + }); + + it('creates public and private threads', async () => { + const s = await setup(); + const publicThread = await startThread(s.owner.token, s.channelId); + expect(publicThread.type).toBe(ChannelTypes.PUBLIC_THREAD); + expect(BigInt(publicThread.id)).toBeGreaterThan(BigInt(s.channelId)); + const privateThread = await startThread(s.owner.token, s.channelId, { + name: 'secret', + type: ChannelTypes.PRIVATE_THREAD, + invitable: false, + rate_limit_per_user: 30, + }); + expect(privateThread.type).toBe(ChannelTypes.PRIVATE_THREAD); + expect(privateThread.thread_metadata?.invitable).toBe(false); + expect(privateThread.rate_limit_per_user).toBe(30); + }); + + it('posts a thread created message in the parent for public threads only', async () => { + const s = await setup(); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const publicThread = await startThread(s.owner.token, s.channelId); + const created = dispatch.mock.calls + .map(([params]) => params) + .filter((params) => params.event === 'MESSAGE_CREATE') + .map((params) => params.data as MessageResponse); + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({type: MessageTypes.THREAD_CREATED, channel_id: s.channelId, content: 'topic'}); + expect(created[0]!.message_reference?.channel_id).toBe(publicThread.id); + dispatch.mockClear(); + await startThread(s.owner.token, s.channelId, {name: 'quiet', type: ChannelTypes.PRIVATE_THREAD}); + expect(dispatch.mock.calls.filter(([params]) => params.event === 'MESSAGE_CREATE')).toEqual([]); + }); + + it('refuses private threads without create private threads', async () => { + const s = await setup(); + await createBuilder(harness, s.owner.token) + .patch(`/guilds/${s.guildId}/roles/${s.guildId}`) + .header(THREADS_FEATURE_HEADER, THREADS_FEATURE) + .body({ + permissions: ( + Permissions.VIEW_CHANNEL | + Permissions.SEND_MESSAGES | + ThreadPermissionFlags.CREATE_PUBLIC_THREADS + ).toString(), + }) + .execute(); + await startThread(s.member.token, s.channelId); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/threads`) + .body({name: 'p', type: ChannelTypes.PRIVATE_THREAD}) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + }); + }); + + describe('membership', () => { + it('joins and leaves a thread', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/@me`) + .expect(204) + .execute(); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/@me`) + .expect(204) + .execute(); + const joined = dispatch.mock.calls.filter(([params]) => params.event === 'THREAD_MEMBERS_UPDATE'); + expect(joined).toHaveLength(1); + expect(joined[0]![0].data).toMatchObject({ + id: thread.id, + guild_id: s.guildId, + member_count: 2, + added_members: [{id: thread.id, user_id: s.member.userId, flags: 0}], + }); + await threadsRequest(harness, s.member.token) + .delete(`/channels/${thread.id}/thread-members/@me`) + .expect(204) + .execute(); + await threadsRequest(harness, s.member.token) + .delete(`/channels/${thread.id}/thread-members/@me`) + .expect(404, APIErrorCodes.UNKNOWN_THREAD_MEMBER) + .execute(); + }); + + it('joins and leaves through a percent-encoded @me', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/%40me`) + .expect(204) + .execute(); + await threadsRequest(harness, s.member.token) + .delete(`/channels/${thread.id}/thread-members/%40me`) + .expect(204) + .execute(); + await threadsRequest(harness, s.member.token) + .delete(`/channels/${thread.id}/thread-members/%40me`) + .expect(404, APIErrorCodes.UNKNOWN_THREAD_MEMBER) + .execute(); + }); + it('refuses joins on archived threads and non-moderator joins on locked threads', async () => { + const s = await setup(); + const archived = await startThread(s.owner.token, s.channelId); + await archive(archived.id); + await threadsRequest(harness, s.member.token) + .put(`/channels/${archived.id}/thread-members/@me`) + .expect(400, APIErrorCodes.THREAD_ARCHIVED) + .execute(); + const locked = await startThread(s.owner.token, s.channelId, {name: 'locked', type: ChannelTypes.PUBLIC_THREAD}); + await lock(locked.id); + await threadsRequest(harness, s.member.token) + .put(`/channels/${locked.id}/thread-members/@me`) + .expect(400, APIErrorCodes.THREAD_LOCKED) + .execute(); + }); + + it('hides private threads from non-members', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId, {name: 'p', type: ChannelTypes.PRIVATE_THREAD}); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/@me`) + .expect(403, APIErrorCodes.MISSING_ACCESS) + .execute(); + }); + + it('adds and removes other members with recipient system messages', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${thread.id}/thread-members/${s.member.userId}`) + .expect(204) + .execute(); + const recipientAdd = dispatch.mock.calls + .map(([params]) => params) + .find((params) => params.event === 'MESSAGE_CREATE')?.data as MessageResponse | undefined; + expect(recipientAdd).toMatchObject({type: MessageTypes.RECIPIENT_ADD, channel_id: thread.id}); + expect(recipientAdd?.mentions.map((user) => user.id)).toEqual([s.member.userId]); + await threadsRequest(harness, s.member.token) + .delete(`/channels/${thread.id}/thread-members/${s.owner.userId}`) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + dispatch.mockClear(); + await threadsRequest(harness, s.owner.token) + .delete(`/channels/${thread.id}/thread-members/${s.member.userId}`) + .expect(204) + .execute(); + const events = dispatch.mock.calls.map(([params]) => params.event); + expect(events).toContain('THREAD_MEMBERS_UPDATE'); + const removed = dispatch.mock.calls.find(([params]) => params.event === 'THREAD_MEMBERS_UPDATE')![0].data; + expect(removed).toMatchObject({removed_member_ids: [s.member.userId], member_count: 1}); + await threadsRequest(harness, s.owner.token) + .delete(`/channels/${thread.id}/thread-members/${s.member.userId}`) + .expect(404, APIErrorCodes.UNKNOWN_THREAD_MEMBER) + .execute(); + }); + + it('posts one recipient message when concurrent adds or removes race past the membership check', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const realGetMember = ThreadRepository.prototype.getMember; + let stale: Awaited> = null; + vi.spyOn(ThreadRepository.prototype, 'getMember').mockImplementation(function (this: ThreadRepository, ...args) { + return args[1].toString() === s.member.userId ? Promise.resolve(stale) : realGetMember.apply(this, args); + }); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const recipientMessages = (type: number) => + dispatch.mock.calls.filter( + ([params]) => params.event === 'MESSAGE_CREATE' && (params.data as MessageResponse).type === type, + ); + const put = () => + threadsRequest(harness, s.owner.token) + .put(`/channels/${thread.id}/thread-members/${s.member.userId}`) + .expect(204) + .execute(); + await Promise.all([put(), put()]); + expect(recipientMessages(MessageTypes.RECIPIENT_ADD)).toHaveLength(1); + stale = await realGetMember.call( + repository.threads as ThreadRepository, + createChannelID(BigInt(thread.id)), + createUserID(BigInt(s.member.userId)), + ); + expect(stale).not.toBeNull(); + const del = () => + threadsRequest(harness, s.owner.token) + .delete(`/channels/${thread.id}/thread-members/${s.member.userId}`) + .expect(204) + .execute(); + await Promise.all([del(), del()]); + expect(recipientMessages(MessageTypes.RECIPIENT_REMOVE)).toHaveLength(1); + }); + + it('refuses adding a user who is not a guild member', async () => { + const s = await setup(); + const outsider = await createTestAccount(harness); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${thread.id}/thread-members/${outsider.userId}`) + .expect(404, APIErrorCodes.UNKNOWN_MEMBER) + .execute(); + }); + + it('refuses adding a member who is outside the experiment like one who cannot view the parent', async () => { + const s = await setup(); + await setChannelThreadsConfig({...ALL_THREADS_ACTIVE, excluded_user_ids: [s.member.userId]}); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${thread.id}/thread-members/${s.member.userId}`) + .expect(403, APIErrorCodes.MISSING_ACCESS) + .execute(); + }); + + it('lets only moderators add non-moderators to a private thread that is not invitable', async () => { + const s = await setup(); + const third = await createTestAccount(harness); + const invite = await createChannelInvite(harness, s.owner.token, s.channelId); + await acceptInvite(harness, third.token, invite.code); + const thread = await startThread(s.owner.token, s.channelId, { + name: 'closed', + type: ChannelTypes.PRIVATE_THREAD, + invitable: false, + }); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${thread.id}/thread-members/${s.member.userId}`) + .expect(204) + .execute(); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/${third.userId}`) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + const role = await threadsRequest(harness, s.owner.token) + .post(`/guilds/${s.guildId}/roles`) + .body({name: 'mods', permissions: ThreadPermissionFlags.MANAGE_THREADS.toString()}) + .execute(); + await addMemberRole(harness, s.owner.token, s.guildId, third.userId, role.id); + const setTimeout = (until: string | null) => + threadsRequest(harness, s.owner.token) + .patch(`/guilds/${s.guildId}/members/${third.userId}`) + .body({communication_disabled_until: until}) + .expect(200) + .execute(); + await setTimeout(new Date(Date.now() + 10 * 60 * 1000).toISOString()); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/${third.userId}`) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + await setTimeout(null); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/${third.userId}`) + .expect(204) + .execute(); + }); + + it('refuses member changes on archived threads', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/@me`) + .expect(204) + .execute(); + await archive(thread.id); + await threadsRequest(harness, s.owner.token) + .delete(`/channels/${thread.id}/thread-members/${s.member.userId}`) + .expect(400, APIErrorCodes.THREAD_ARCHIVED) + .execute(); + await threadsRequest(harness, s.member.token) + .delete(`/channels/${thread.id}/thread-members/@me`) + .expect(400, APIErrorCodes.THREAD_ARCHIVED) + .execute(); + }); + + it('rejects thread member routes on channels that are not threads', async () => { + const s = await setup(); + await threadsRequest(harness, s.member.token) + .put(`/channels/${s.channelId}/thread-members/@me`) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + }); + }); + + describe('bot-only member reads', () => { + it('lists thread members paginated with and without guild members', async () => { + const s = await setup(); + const bot = await addBot(s); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.member.token) + .put(`/channels/${thread.id}/thread-members/@me`) + .expect(204) + .execute(); + const token = `Bot ${bot.botToken}`; + const all = await createBuilder>(harness, token) + .get(`/channels/${thread.id}/thread-members`) + .execute(); + const expectedOrder = [s.owner.userId, s.member.userId].sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1)); + expect(all.map((member) => member.user_id)).toEqual(expectedOrder); + expect(all.every((member) => member.member === undefined && member.muted === undefined)).toBe(true); + const firstPage = await createBuilder>(harness, token) + .get(`/channels/${thread.id}/thread-members?limit=1&with_member=true`) + .execute(); + expect(firstPage).toHaveLength(1); + expect(firstPage[0]!.member?.user.id).toBe(expectedOrder[0]); + const secondPage = await createBuilder>(harness, token) + .get(`/channels/${thread.id}/thread-members?limit=1&with_member=true&after=${expectedOrder[0]}`) + .execute(); + expect(secondPage.map((member) => member.user_id)).toEqual([expectedOrder[1]]); + const plainFirst = await createBuilder>(harness, token) + .get(`/channels/${thread.id}/thread-members?limit=1`) + .execute(); + expect(plainFirst.map((member) => member.user_id)).toEqual([expectedOrder[0]]); + const plainSecond = await createBuilder>(harness, token) + .get(`/channels/${thread.id}/thread-members?limit=1&after=${expectedOrder[0]}`) + .execute(); + expect(plainSecond.map((member) => member.user_id)).toEqual([expectedOrder[1]]); + expect(plainSecond[0]!.member).toBeUndefined(); + await createBuilder(harness, token).get(`/channels/${thread.id}/thread-members?limit=101`).expect(400).execute(); + }); + + it('gets one thread member and 404s for non-members', async () => { + const s = await setup(); + const bot = await addBot(s); + const thread = await startThread(s.owner.token, s.channelId); + const token = `Bot ${bot.botToken}`; + const owner = await createBuilder(harness, token) + .get(`/channels/${thread.id}/thread-members/${s.owner.userId}?with_member=true`) + .execute(); + expect(owner).toMatchObject({id: thread.id, user_id: s.owner.userId, flags: ThreadMemberFlags.HAS_INTERACTED}); + expect(owner.member?.user.id).toBe(s.owner.userId); + await createBuilder(harness, token) + .get(`/channels/${thread.id}/thread-members/${s.member.userId}`) + .expect(404, APIErrorCodes.UNKNOWN_THREAD_MEMBER) + .execute(); + }); + + it('lists active guild threads the bot can see, newest first', async () => { + const s = await setup(); + const bot = await addBot(s); + const first = await startThread(s.owner.token, s.channelId); + const second = await startThread(s.owner.token, s.channelId, {name: 'p', type: ChannelTypes.PRIVATE_THREAD}); + const archived = await startThread(s.owner.token, s.channelId, {name: 'old', type: ChannelTypes.PUBLIC_THREAD}); + await archive(archived.id); + const token = `Bot ${bot.botToken}`; + await threadsRequest(harness, token).put(`/channels/${first.id}/thread-members/@me`).expect(204).execute(); + const response = await createBuilder(harness, token) + .get(`/guilds/${s.guildId}/threads/active`) + .execute(); + expect(response.threads.map((thread) => thread.id)).toEqual([second.id, first.id]); + expect(response.members.map((member) => [member.id, member.user_id])).toEqual([[first.id, bot.botUserId]]); + }); + + it('hides private threads from bots without manage threads that are not members', async () => { + const s = await setup(); + const bot = await addBot(s, Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY); + await startThread(s.owner.token, s.channelId, {name: 'p', type: ChannelTypes.PRIVATE_THREAD}); + const open = await startThread(s.owner.token, s.channelId); + const response = await createBuilder(harness, `Bot ${bot.botToken}`) + .get(`/guilds/${s.guildId}/threads/active`) + .execute(); + expect(response.threads.map((thread) => thread.id)).toEqual([open.id]); + }); + + it('hides private threads from timed out thread moderators that are not members', async () => { + const s = await setup(); + const bot = await addBot( + s, + Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY | ThreadPermissionFlags.MANAGE_THREADS, + ); + const hidden = await startThread(s.owner.token, s.channelId, {name: 'p', type: ChannelTypes.PRIVATE_THREAD}); + const open = await startThread(s.owner.token, s.channelId); + const list = async () => + ( + await createBuilder(harness, `Bot ${bot.botToken}`) + .get(`/guilds/${s.guildId}/threads/active`) + .execute() + ).threads.map((thread) => thread.id); + expect(await list()).toEqual([open.id, hidden.id]); + await threadsRequest(harness, s.owner.token) + .patch(`/guilds/${s.guildId}/members/${bot.botUserId}`) + .body({communication_disabled_until: new Date(Date.now() + 10 * 60 * 1000).toISOString()}) + .expect(200) + .execute(); + expect(await list()).toEqual([open.id]); + }); + }); + + describe('archived lists', () => { + it('pages public archived threads by archive timestamp', async () => { + const s = await setup(); + const ids: Array = []; + const archivedAt = Date.now() - 60_000; + for (const [index, name] of ['a', 'b', 'c'].entries()) { + const thread = await startThread(s.owner.token, s.channelId, {name, type: ChannelTypes.PUBLIC_THREAD}); + await archive(thread.id, new Date(archivedAt + index * 1000)); + ids.push(thread.id); + } + await startThread(s.owner.token, s.channelId); + const page = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.channelId}/threads/archived/public?limit=2`) + .execute(); + expect(page.threads.map((thread) => thread.id)).toEqual([ids[2], ids[1]]); + expect(page.has_more).toBe(true); + expect(page.members.map((member) => member.id)).toEqual([ids[2], ids[1]]); + expect(page.members[0]!.muted).toBe(false); + const before = page.threads[1]!.thread_metadata!.archive_timestamp; + const rest = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.channelId}/threads/archived/public?before=${encodeURIComponent(before)}`) + .execute(); + expect(rest.threads.map((thread) => thread.id)).toEqual([ids[0]]); + expect(rest.has_more).toBe(false); + await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.channelId}/threads/archived/public?limit=1`) + .expect(400) + .execute(); + }); + + it('requires manage threads for private archived threads and lists joined ones for members', async () => { + const s = await setup(); + await createBuilder(harness, s.owner.token) + .patch(`/guilds/${s.guildId}/roles/${s.guildId}`) + .header(THREADS_FEATURE_HEADER, THREADS_FEATURE) + .body({ + permissions: ( + Permissions.VIEW_CHANNEL | + Permissions.SEND_MESSAGES | + Permissions.READ_MESSAGE_HISTORY | + ThreadPermissionFlags.CREATE_PRIVATE_THREADS | + ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS + ).toString(), + }) + .execute(); + const mine = await startThread(s.member.token, s.channelId, {name: 'mine', type: ChannelTypes.PRIVATE_THREAD}); + const theirs = await startThread(s.owner.token, s.channelId, {name: 'x', type: ChannelTypes.PRIVATE_THREAD}); + await archive(mine.id); + await archive(theirs.id); + await threadsRequest(harness, s.member.token) + .get(`/channels/${s.channelId}/threads/archived/private`) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + const all = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.channelId}/threads/archived/private`) + .execute(); + expect(all.threads.map((thread) => thread.id).sort()).toEqual([mine.id, theirs.id].sort()); + const joined = await threadsRequest(harness, s.member.token) + .get(`/channels/${s.channelId}/users/@me/threads/archived/private`) + .execute(); + expect(joined.threads.map((thread) => thread.id)).toEqual([mine.id]); + expect(joined.members.map((member) => member.user_id)).toEqual([s.member.userId]); + const empty = await threadsRequest(harness, s.member.token) + .get(`/channels/${s.channelId}/users/@me/threads/archived/private?before=${mine.id}`) + .execute(); + expect(empty).toEqual({threads: [], members: [], has_more: false}); + }); + + it('rejects private lists on threads', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.owner.token) + .get(`/channels/${thread.id}/threads/archived/private`) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + }); + }); + + describe('announcement parents', () => { + it('refuses a thread whose parent became an announcement channel mid-create', async () => { + const s = await setup(); + const parentId = createChannelID(BigInt(s.channelId)); + const count = ThreadRepository.prototype.countActiveThreads; + vi.spyOn(ThreadRepository.prototype, 'countActiveThreads').mockImplementationOnce(async function ( + this: ThreadRepository, + guildId, + ) { + const parent = await repository.channelData.findUnique(parentId); + await repository.channelData.upsert({...parent!.toRow(), type: ChannelTypes.GUILD_ANNOUNCEMENT}); + return count.call(this, guildId); + }); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/threads`) + .body({name: 'late', type: ChannelTypes.PUBLIC_THREAD}) + .expect(400, APIErrorCodes.INVALID_CHANNEL_TYPE) + .execute(); + expect(await repository.threads.listThreadIdsByParent(parentId, {limit: 1})).toEqual([]); + }); + }); + + describe('admin', () => { + it('lists every thread of a guild and deletes threads even after the experiment is off', async () => { + const s = await setup(); + const admin = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]); + const open = await startThread(s.owner.token, s.channelId); + const archived = await startThread(s.owner.token, s.channelId, {name: 'old', type: ChannelTypes.PUBLIC_THREAD}); + await archive(archived.id); + await setChannelThreadsConfig({enabled: false}); + const listed = await createBuilder<{threads: Array}>(harness, admin.token) + .get(`/admin/guilds/${s.guildId}/threads`) + .execute(); + expect(listed.threads.map((thread) => thread.id)).toEqual([archived.id, open.id]); + await createBuilder(harness, admin.token).delete(`/admin/channels/${s.channelId}`).expect(400).execute(); + await createBuilder(harness, admin.token).delete(`/admin/channels/${open.id}`).expect(204).execute(); + expect(await repository.threads.getState(createChannelID(BigInt(open.id)))).toBeNull(); + expect(await repository.findUnique(createChannelID(BigInt(open.id)))).toBeNull(); + const after = await createBuilder<{threads: Array}>(harness, admin.token) + .get(`/admin/guilds/${s.guildId}/threads`) + .execute(); + expect(after.threads.map((thread) => thread.id)).toEqual([archived.id]); + }); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/ThreadTestUtils.ts b/fluxer_api/src/api/channel/tests/ThreadTestUtils.ts new file mode 100644 index 000000000..6b0b5e8d7 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ThreadTestUtils.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + clearChannelThreadsTaintCacheForTesting, + syncChannelThreadsConfig, +} from '@app/api/experiment/ChannelThreadsGate'; +import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; +import type {ApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createBuilder, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder'; +import { + type ChannelThreadsConfig, + ChannelThreadsConfigSchema, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; + +export const THREADS_FEATURE_HEADER = 'X-Fluxer-Features'; +export const THREADS_FEATURE = 'channel_threads'; + +export const ALL_THREADS_ACTIVE: Partial = { + enabled: true, + guild_basis_points: 10000, + user_basis_points: 10000, +}; + +export async function setChannelThreadsConfig(patch: Partial): Promise { + await getInstanceConfigRepository().updateChannelThreadsConfig((current) => + ChannelThreadsConfigSchema.parse({ + ...patch, + ever_enabled: current.ever_enabled || patch.enabled === true, + config_version: current.config_version + 1, + }), + ); + clearChannelThreadsTaintCacheForTesting(); +} + +export function resetChannelThreadsConfig(): void { + syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + clearChannelThreadsTaintCacheForTesting(); +} + +export function threadsRequest( + harness: ApiTestHarness, + token: string, + options: {capable?: boolean} = {}, +): TestRequestBuilder { + const builder = createBuilder(harness, token); + return options.capable === false ? builder : builder.header(THREADS_FEATURE_HEADER, THREADS_FEATURE); +} diff --git a/fluxer_api/src/api/channel/tests/ThreadsKillSwitch.test.ts b/fluxer_api/src/api/channel/tests/ThreadsKillSwitch.test.ts new file mode 100644 index 000000000..403d6b119 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ThreadsKillSwitch.test.ts @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; +import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_PERMISSIONS, ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {GuildAuditLogListResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; +import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; + +interface Setup { + owner: TestAccount; + guildId: string; + channelId: string; + thread: ThreadChannelResponse; + role: GuildRoleResponse; +} + +describe('channel_threads kill switch', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'kill switch'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const thread = await threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'topic', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + const role = await threadsRequest(harness, owner.token) + .post(`/guilds/${guild.id}/roles`) + .body({name: 'mods', permissions: (Permissions.SEND_MESSAGES | ThreadPermissionFlags.MANAGE_THREADS).toString()}) + .execute(); + await threadsRequest(harness, owner.token) + .patch(`/guilds/${guild.id}/roles/${role.id}`) + .body({permissions: Permissions.SEND_MESSAGES.toString()}) + .execute(); + return {owner, guildId: guild.id, channelId: channel.id, thread, role}; + } + + async function auditLog(s: Setup): Promise { + return threadsRequest(harness, s.owner.token) + .get(`/guilds/${s.guildId}/audit-logs`) + .execute(); + } + + function roleEntries(log: GuildAuditLogListResponse, roleId: string, action: AuditLogActionType) { + return log.audit_log_entries.filter((entry) => entry.target_id === roleId && entry.action_type === action); + } + + function permissionsChange(entry: GuildAuditLogListResponse['audit_log_entries'][number] | undefined) { + return entry?.changes?.find((change) => change.key === 'permissions'); + } + + test('viewers see thread entries and thread bits while the guild is active', async () => { + const s = await setup(); + const log = await auditLog(s); + expect(log.audit_log_entries.map((entry) => entry.action_type)).toContain(AuditLogActionType.THREAD_CREATE); + const created = permissionsChange(roleEntries(log, s.role.id, AuditLogActionType.ROLE_CREATE)[0]); + expect(BigInt(created?.new_value as string) & THREAD_PERMISSIONS).toBe(ThreadPermissionFlags.MANAGE_THREADS); + expect(roleEntries(log, s.role.id, AuditLogActionType.ROLE_UPDATE)).toHaveLength(1); + }); + + test('after the kill switch threads are unknown and the audit log is filtered and masked', async () => { + const s = await setup(); + await setChannelThreadsConfig({enabled: false}); + await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.thread.id}`) + .expect(404, APIErrorCodes.UNKNOWN_CHANNEL) + .execute(); + const log = await auditLog(s); + expect(log).not.toHaveProperty('threads'); + expect(log.audit_log_entries.map((entry) => entry.action_type)).not.toContain(AuditLogActionType.THREAD_CREATE); + expect(log.audit_log_entries.some((entry) => entry.target_id === s.thread.id)).toBe(false); + const created = permissionsChange(roleEntries(log, s.role.id, AuditLogActionType.ROLE_CREATE)[0]); + expect(BigInt(created?.new_value as string)).toBe(Permissions.SEND_MESSAGES); + expect(roleEntries(log, s.role.id, AuditLogActionType.ROLE_UPDATE)).toHaveLength(0); + }); + + test('after the kill switch the gateway channels collection keeps the taint marker only', async () => { + const s = await setup(); + await setChannelThreadsConfig({enabled: false}); + const {data} = await createBuilder<{data: Record}>(harness, '') + .post('/test/rpc-session-init') + .body({type: 'guild_collection', guild_id: s.guildId, collection: 'channels'}) + .execute(); + expect(data.thread_tainted).toBe(true); + for (const key of ['thread_gate', 'threads', 'thread_members', 'thread_only_channels']) { + expect(data).not.toHaveProperty(key); + } + }); +}); diff --git a/fluxer_api/src/api/channel/threads/ChannelThreadsRouteGuard.ts b/fluxer_api/src/api/channel/threads/ChannelThreadsRouteGuard.ts new file mode 100644 index 000000000..e51ff24a6 --- /dev/null +++ b/fluxer_api/src/api/channel/threads/ChannelThreadsRouteGuard.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createChannelID} from '@app/api/BrandedTypes'; +import { + channelThreadsEnabled, + guildActive, + userViewerActive, + viewerFromCtx, +} from '@app/api/experiment/ChannelThreadsGate'; +import type {HonoEnv} from '@app/api/types/HonoEnv'; +import {AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers'; +import type {Context} from 'hono'; +import {createMiddleware} from 'hono/factory'; + +const SNOWFLAKE_PARAM = /^\d{1,20}$/; + +function callerActive(ctx: Context, botOnly: boolean): boolean { + if (!channelThreadsEnabled()) return false; + const user = ctx.get('user'); + if (!user || (botOnly && !user.isBot)) return false; + return userViewerActive(viewerFromCtx(ctx)); +} + +function pathGuildActive(ctx: Context): boolean { + const guildId = ctx.req.param('guild_id'); + if (guildId !== undefined) { + return SNOWFLAKE_PARAM.test(guildId) && guildActive(guildId); + } + const channelId = ctx.req.param('channel_id'); + if (channelId === undefined) return true; + if (!SNOWFLAKE_PARAM.test(channelId)) return false; + const channel = ctx.get('requestCache').channels.get(createChannelID(BigInt(channelId))); + return channel?.guildId != null && guildActive(channel.guildId); +} + +export function ChannelThreadsRouteGuard(options: {botOnly?: boolean} = {}) { + const botOnly = options.botOnly ?? false; + return createMiddleware(async (ctx, next) => { + if (!callerActive(ctx, botOnly) || !pathGuildActive(ctx)) { + return AppNotFoundHandler(ctx); + } + return next(); + }); +} diff --git a/fluxer_api/src/api/channel/threads/GatedJsonValidator.ts b/fluxer_api/src/api/channel/threads/GatedJsonValidator.ts new file mode 100644 index 000000000..d9a660bd8 --- /dev/null +++ b/fluxer_api/src/api/channel/threads/GatedJsonValidator.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {HonoEnv} from '@app/api/types/HonoEnv'; +import {readRequestJsonBody} from '@app/api/utils/RequestJsonBody'; +import {Validator} from '@app/api/Validator'; +import type {Context, MiddlewareHandler} from 'hono'; +import type {output, ZodType} from 'zod'; + +interface GatedJsonValidatorOptions { + touchesGate: (body: unknown, ctx: Context) => boolean; + active: (ctx: Context, body: unknown) => boolean | Promise; + pre?: (raw: unknown, ctx: Context) => unknown | Promise; +} + +export function GatedJsonValidator( + control: Control, + gated: Gated, + options: GatedJsonValidatorOptions, +): MiddlewareHandler | output}}> { + const validatorOptions = options.pre ? {pre: options.pre} : undefined; + const controlValidator = Validator('json', control, validatorOptions) as unknown as MiddlewareHandler; + const gatedValidator = Validator('json', gated, validatorOptions) as unknown as MiddlewareHandler; + return async (ctx, next) => { + const body = await readRequestJsonBody(ctx.req); + const useGated = body.parsed && options.touchesGate(body.value, ctx) && (await options.active(ctx, body.value)); + return (useGated ? gatedValidator : controlValidator)(ctx, next); + }; +} diff --git a/fluxer_api/src/api/channel/threads/ThreadJobs.ts b/fluxer_api/src/api/channel/threads/ThreadJobs.ts new file mode 100644 index 000000000..c9cc00070 --- /dev/null +++ b/fluxer_api/src/api/channel/threads/ThreadJobs.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; +import {getCompiledChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate'; +import {Logger} from '@app/api/Logger'; + +const REPAIR_DEBOUNCE_MS = 30_000; +const THREAD_SEARCH_ACTIVITY_WINDOW_MS = 30_000; + +async function workerService() { + const {getWorkerService} = await import('@app/api/middleware/ServiceRegistry'); + return getWorkerService(); +} + +export async function enqueueDeleteChannelThreads(guildId: GuildID, parentId: ChannelID): Promise { + await (await workerService()).addJob( + 'deleteChannelThreads', + {guildId: guildId.toString(), parentId: parentId.toString()}, + {jobKey: `delete-channel-threads-${parentId}`}, + ); +} + +export async function enqueueRemoveThreadMemberships(guildId: GuildID, userId: UserID): Promise { + await (await workerService()).addJob( + 'removeThreadMembershipsForGuildMember', + {guildId: guildId.toString(), userId: userId.toString()}, + {jobKey: `remove-thread-memberships-${guildId}-${userId}`}, + ); +} + +export async function enqueueRebuildThreadAutoArchiveQueue(guildId: string): Promise { + await (await workerService()).addJob( + 'rebuildThreadAutoArchiveQueue', + {guildId, configVersion: getCompiledChannelThreadsConfig().config.config_version}, + {jobKey: `rebuild-thread-archive-queue-${guildId}`}, + ); +} + +export function enqueueRepairThreadIndexes(threadIds: Array): void { + if (threadIds.length === 0) return; + const window = Math.floor(Date.now() / REPAIR_DEBOUNCE_MS); + const ids = [...new Set(threadIds.map((id) => id.toString()))].sort().slice(0, 1000); + workerService() + .then((service) => + service.addJob('repairThreadIndexes', {threadIds: ids}, {jobKey: `repair-thread-indexes-${ids[0]}-${window}`}), + ) + .catch((error) => Logger.warn({error}, 'Failed to enqueue thread index repair')); +} + +export function enqueueThreadSearchSync(threadId: ChannelID, opts: {activity?: boolean} = {}): void { + const window = Math.floor(Date.now() / THREAD_SEARCH_ACTIVITY_WINDOW_MS); + workerService() + .then((service) => + service.addJob( + 'syncThreadSearchDocument', + {threadId: threadId.toString()}, + opts.activity + ? { + jobKey: `sync-thread-search-${threadId}-${window}`, + runAt: new Date((window + 1) * THREAD_SEARCH_ACTIVITY_WINDOW_MS), + } + : undefined, + ), + ) + .catch((error) => Logger.warn({error}, 'Failed to enqueue thread search sync')); +} + +export async function enqueueThreadSearchBackfill(guildId: string): Promise { + await (await workerService()).addJob( + 'backfillThreadSearch', + {guildId, configVersion: getCompiledChannelThreadsConfig().config.config_version}, + {jobKey: `backfill-thread-search-${guildId}`}, + ); +} diff --git a/fluxer_api/src/api/channel/threads/ThreadMetrics.ts b/fluxer_api/src/api/channel/threads/ThreadMetrics.ts new file mode 100644 index 000000000..3582ebb61 --- /dev/null +++ b/fluxer_api/src/api/channel/threads/ThreadMetrics.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {registerCounter, registerGauge, registerHistogram} from '@fluxer/hono/src/middleware/Metrics'; + +let sweepLagSeconds = 0; + +export const threadsCreatedTotal = registerCounter( + 'fluxer_api_threads_created_total', + 'Threads created, by kind and channel type', +); +export const threadsArchivedTotal = registerCounter('fluxer_api_threads_archived_total', 'Threads archived, by reason'); +export const threadArchiveSweepBatch = registerHistogram( + 'fluxer_api_thread_archive_sweep_batch', + 'Threads archived per auto-archive sweep tick', + [0, 1, 10, 50, 100, 200], +); +export const threadArchiveSweepSkippedInactiveTotal = registerCounter( + 'fluxer_api_thread_archive_sweep_skipped_inactive_total', + 'Guilds skipped by the auto-archive sweep because the experiment is inactive for them', +); +registerGauge( + 'fluxer_api_thread_archive_sweep_lag_seconds', + 'Age of the oldest overdue thread seen by the last auto-archive sweep', + () => sweepLagSeconds, +); +export const threadMemberAutojoinTotal = registerCounter( + 'fluxer_api_thread_member_autojoin_total', + 'Thread members added automatically, by source', +); +export const threadSearchIndexEnqueueCappedTotal = registerCounter( + 'fluxer_api_thread_search_index_enqueue_capped_total', + 'Guild searches whose channel index enqueue was capped', +); +export const webhookThreadIdRefusedTotal = registerCounter( + 'fluxer_api_webhook_thread_id_refused_total', + 'Webhook requests refused because thread_id is not usable outside the experiment', +); + +export const threadRoleMentionCappedTotal = registerCounter( + 'fluxer_api_thread_role_mention_capped_total', + 'Thread messages whose role mentions hit the member add cap', +); + +export function setThreadArchiveSweepLag(seconds: number): void { + sweepLagSeconds = Math.max(0, seconds); +} diff --git a/fluxer_api/src/api/channel/threads/tests/ChannelThreadsControlIdentity.test.ts b/fluxer_api/src/api/channel/threads/tests/ChannelThreadsControlIdentity.test.ts new file mode 100644 index 000000000..f60b70d83 --- /dev/null +++ b/fluxer_api/src/api/channel/threads/tests/ChannelThreadsControlIdentity.test.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createDmChannel, createFriendship, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution'; +import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes'; +import { + clearChannelThreadsTaintCacheForTesting, + syncChannelThreadsConfig, +} from '@app/api/experiment/ChannelThreadsGate'; +import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import { + applyChannelThreadsConfigUpdate, + ChannelThreadsConfigSchema, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +class RecordingExecutor extends InMemoryCassandraQueryExecutor { + statements: Array = []; + + override async executeQuery, P extends CassandraParams = CassandraParams>( + query: PreparedQuery

, + ): Promise> { + this.statements.push(`${query.cql}|${Object.keys(query.params ?? {}).sort()}`); + return super.executeQuery(query); + } + + override async executeBatch( + queries: Array<{query: string; params: object; meta?: KvQueryMeta}>, + atomic?: boolean, + ): Promise { + for (const entry of queries) this.statements.push(`${entry.query}|${Object.keys(entry.params).sort()}`); + return super.executeBatch(queries, atomic); + } +} + +function resetConfig(): void { + syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + clearChannelThreadsTaintCacheForTesting(); +} + +describe('channel threads control identity', () => { + let harness: ApiTestHarness; + let executor: RecordingExecutor; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetConfig(); + executor = new RecordingExecutor(); + setCassandraQueryExecutorForTesting(executor); + }); + + afterEach(() => { + resetConfig(); + }); + + afterAll(async () => { + setCassandraQueryExecutorForTesting(null); + await harness?.shutdown(); + }); + + async function recorded(action: () => Promise): Promise<{result: T; statements: Array}> { + executor.statements = []; + const result = await action(); + return {result, statements: [...executor.statements].sort()}; + } + + async function createPair() { + const owner = await createTestAccount(harness); + const friend = await createTestAccount(harness); + await createFriendship(harness, owner, friend); + return {owner, friend}; + } + + const loadChannels = (guildId: string) => + createBuilder<{channels: unknown}>(harness, '') + .post('/test/rpc-session-init') + .body({type: 'guild_collection', guild_id: guildId, collection: 'channels'}) + .expect(HTTP_STATUS.OK) + .execute(); + + it('keeps guild create, DM create and RPC channels identical for a guild outside the experiment', async () => { + const baseline = await createPair(); + const baselineGuild = await recorded(() => createGuild(harness, baseline.owner.token, 'control')); + const baselineDm = await recorded(() => createDmChannel(harness, baseline.owner.token, baseline.friend.userId)); + const baselineChannels = await loadChannels(baselineGuild.result.id); + + await getInstanceConfigRepository().updateChannelThreadsConfig((current) => + applyChannelThreadsConfigUpdate(current, {enabled: true, enabled_guild_ids: ['1']}), + ); + expect((await getInstanceConfigRepository().refreshChannelThreadsConfig()).config.ever_enabled).toBe(true); + + const enrolledOff = await createPair(); + const guild = await recorded(() => createGuild(harness, enrolledOff.owner.token, 'control')); + const dm = await recorded(() => createDmChannel(harness, enrolledOff.owner.token, enrolledOff.friend.userId)); + + expect(baselineGuild.statements.length).toBeGreaterThan(0); + expect(baselineDm.statements.length).toBeGreaterThan(0); + expect(guild.statements).toEqual(baselineGuild.statements); + expect(dm.statements).toEqual(baselineDm.statements); + expect(await loadChannels(baselineGuild.result.id)).toEqual(baselineChannels); + }); +}); diff --git a/fluxer_api/src/api/channel/threads/tests/ChannelThreadsRouteGuard.test.ts b/fluxer_api/src/api/channel/threads/tests/ChannelThreadsRouteGuard.test.ts new file mode 100644 index 000000000..79a04fab9 --- /dev/null +++ b/fluxer_api/src/api/channel/threads/tests/ChannelThreadsRouteGuard.test.ts @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils'; +import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import {ChannelThreadsRouteGuard} from '@app/api/channel/threads/ChannelThreadsRouteGuard'; +import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution'; +import type {CassandraParams, PreparedQuery} from '@app/api/database/CassandraTypes'; +import {BotOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; +import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; +import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; +import {RateLimitConfigs} from '@app/api/RateLimitConfig'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import type {HonoApp} from '@app/api/types/HonoEnv'; +import {Validator} from '@app/api/Validator'; +import { + applyChannelThreadsConfigUpdate, + type ChannelThreadsConfigUpdateRequest, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {ChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import {z} from 'zod'; + +class RecordingExecutor extends InMemoryCassandraQueryExecutor { + reads: Array = []; + + override async executeQuery, P extends CassandraParams = CassandraParams>( + query: PreparedQuery

, + ): Promise> { + if (query.cql.trimStart().toUpperCase().startsWith('SELECT')) { + this.reads.push(query.cql); + } + return super.executeQuery(query); + } +} + +const SentinelBody = z.object({name: z.string()}); + +function registerSentinelRoutes(routes: HonoApp): void { + routes.get( + '/channels/:channel_id/threads-sentinel', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.DEFAULT), + LoginRequired, + Validator('param', ChannelIdParam), + (ctx) => ctx.json({ok: true}), + ); + routes.post( + '/channels/:channel_id/threads-sentinel', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.DEFAULT), + LoginRequired, + Validator('param', ChannelIdParam), + Validator('json', SentinelBody), + (ctx) => ctx.json({ok: true}), + ); + routes.get( + '/guilds/:guild_id/threads-sentinel', + ChannelThreadsRouteGuard(), + RateLimitMiddleware(RateLimitConfigs.DEFAULT), + LoginRequired, + Validator('param', GuildIdParam), + (ctx) => ctx.json({ok: true}), + ); + routes.get( + '/channels/:channel_id/threads-sentinel-bot', + ChannelThreadsRouteGuard({botOnly: true}), + RateLimitMiddleware(RateLimitConfigs.DEFAULT), + LoginRequired, + BotOnly, + (ctx) => ctx.json({ok: true}), + ); +} + +interface Snapshot { + status: number; + body: string; + headers: Array<[string, string]>; + reads: Array; +} + +const CAPABLE = {'X-Fluxer-Features': 'channel_threads'}; + +describe('ChannelThreadsRouteGuard', () => { + let harness: ApiTestHarness; + let executor: RecordingExecutor; + let owner: TestAccount; + let guildId: string; + let channelId: string; + + beforeAll(async () => { + harness = await createApiTestHarness({registerRoutes: registerSentinelRoutes}); + }); + + beforeEach(async () => { + await harness.reset(); + executor = new RecordingExecutor(); + setCassandraQueryExecutorForTesting(executor); + owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'threads guard'); + guildId = guild.id; + channelId = (await createChannel(harness, owner.token, guildId, 'general')).id; + }); + + afterAll(async () => { + setCassandraQueryExecutorForTesting(null); + await harness.shutdown(); + }); + + async function setConfig(update: ChannelThreadsConfigUpdateRequest): Promise { + await getInstanceConfigRepository().updateChannelThreadsConfig((current) => + applyChannelThreadsConfigUpdate(current, update), + ); + } + + async function snapshot( + path: string, + options: {method?: string; token?: string; headers?: Record; body?: string} = {}, + ): Promise { + const headers: Record = {'x-fluxer-test-enable-rate-limits': 'true', ...options.headers}; + if (options.token) headers.Authorization = options.token; + executor.reads = []; + const response = await harness.requestJson({path, method: options.method ?? 'GET', headers, body: options.body}); + const reads = executor.reads; + return { + status: response.status, + body: await response.text(), + headers: [...response.headers].filter(([name]) => name !== 'x-request-id'), + reads, + }; + } + + async function expectLikeUnregistered( + sentinelPath: string, + unregisteredPath: string, + options: {method?: string; token?: string; headers?: Record; body?: string} = {}, + ): Promise { + await snapshot(unregisteredPath, options); + const sentinel = await snapshot(sentinelPath, options); + const unregistered = await snapshot(unregisteredPath, options); + expect(sentinel.status).toBe(404); + expect(sentinel).toEqual(unregistered); + expect(sentinel.headers.some(([name]) => name.startsWith('x-ratelimit'))).toBe(false); + } + + const sentinel = () => `/channels/${channelId}/threads-sentinel`; + const unregistered = () => `/channels/${channelId}/threads-unregistered`; + + it('answers like an unregistered path while the experiment is off', async () => { + await expectLikeUnregistered(sentinel(), unregistered(), {token: owner.token, headers: CAPABLE}); + }); + + it('answers like an unregistered path for a user who is not enrolled', async () => { + await setConfig({enabled: true, enabled_guild_ids: [guildId]}); + await expectLikeUnregistered(sentinel(), unregistered(), {token: owner.token, headers: CAPABLE}); + }); + + it('answers like an unregistered path without the client capability', async () => { + await setConfig({enabled: true, enabled_guild_ids: [guildId], included_user_ids: [owner.userId]}); + await expectLikeUnregistered(sentinel(), unregistered(), {token: owner.token}); + }); + + it('answers like an unregistered path for an excluded user', async () => { + await setConfig({ + enabled: true, + enabled_guild_ids: [guildId], + included_user_ids: [owner.userId], + excluded_user_ids: [owner.userId], + }); + await expectLikeUnregistered(sentinel(), unregistered(), {token: owner.token, headers: CAPABLE}); + }); + + it('answers like an unregistered path for an OAuth2 bearer token', async () => { + await setConfig({enabled: true, enabled_guild_ids: [guildId], included_user_ids: [owner.userId]}); + const bearer = await createBuilder<{token: string}>(harness, '') + .post('/test/oauth2/access-token') + .body({user_id: owner.userId, scopes: ['identify', 'guilds']}) + .execute(); + await expectLikeUnregistered(sentinel(), unregistered(), {token: `Bearer ${bearer.token}`, headers: CAPABLE}); + }); + + it('answers like an unregistered path when unauthenticated', async () => { + await setConfig({enabled: true, enabled_guild_ids: [guildId], user_basis_points: 10000}); + await expectLikeUnregistered(sentinel(), unregistered(), {headers: CAPABLE}); + }); + + it('answers like an unregistered path for a malformed snowflake', async () => { + await expectLikeUnregistered('/channels/not-a-snowflake/threads-sentinel', '/channels/not-a-snowflake/threads-x', { + token: owner.token, + headers: CAPABLE, + }); + }); + + it('answers like an unregistered path for a malformed body', async () => { + await expectLikeUnregistered(sentinel(), unregistered(), { + method: 'POST', + token: owner.token, + headers: CAPABLE, + body: '{"name":', + }); + }); + + it('answers like an unregistered path for a user token on a bot-only route', async () => { + await setConfig({enabled: true, enabled_guild_ids: [guildId], included_user_ids: [owner.userId]}); + await expectLikeUnregistered(`/channels/${channelId}/threads-sentinel-bot`, unregistered(), { + token: owner.token, + headers: CAPABLE, + }); + }); + + it('answers like an unregistered path when the path guild is not enrolled', async () => { + await setConfig({enabled: true, included_user_ids: [owner.userId]}); + await expectLikeUnregistered(sentinel(), unregistered(), {token: owner.token, headers: CAPABLE}); + await expectLikeUnregistered(`/guilds/${guildId}/threads-sentinel`, `/guilds/${guildId}/threads-unregistered`, { + token: owner.token, + headers: CAPABLE, + }); + }); + + it('lets an enrolled capable user through on an enrolled guild', async () => { + await setConfig({enabled: true, enabled_guild_ids: [guildId], included_user_ids: [owner.userId]}); + const channel = await snapshot(sentinel(), {token: owner.token, headers: CAPABLE}); + expect(channel.status).toBe(200); + const guild = await snapshot(`/guilds/${guildId}/threads-sentinel`, {token: owner.token, headers: CAPABLE}); + expect(guild.status).toBe(200); + }); + + it('lets a bot through by the guild gate alone unless it is excluded', async () => { + const bot = await createTestBotAccount(harness); + await setConfig({enabled: true, enabled_guild_ids: [guildId]}); + const allowed = await snapshot(`/channels/${channelId}/threads-sentinel-bot`, {token: `Bot ${bot.botToken}`}); + expect(allowed.status).toBe(200); + expect(allowed.headers.some(([name]) => name.startsWith('x-ratelimit'))).toBe(true); + await setConfig({excluded_user_ids: [bot.botUserId]}); + await expectLikeUnregistered(`/channels/${channelId}/threads-sentinel-bot`, unregistered(), { + token: `Bot ${bot.botToken}`, + }); + }); +}); diff --git a/fluxer_api/src/api/channel/threads/tests/GatedJsonValidator.test.ts b/fluxer_api/src/api/channel/threads/tests/GatedJsonValidator.test.ts new file mode 100644 index 000000000..fceb84641 --- /dev/null +++ b/fluxer_api/src/api/channel/threads/tests/GatedJsonValidator.test.ts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {GatedJsonValidator} from '@app/api/channel/threads/GatedJsonValidator'; +import type {HonoEnv} from '@app/api/types/HonoEnv'; +import {Validator} from '@app/api/Validator'; +import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers'; +import {Hono} from 'hono'; +import {describe, expect, it} from 'vitest'; +import {z} from 'zod'; + +const Control = z.object({name: z.string().max(10)}); +const Gated = Control.extend({default_auto_archive_duration: z.number().int()}); + +function touchesGate(body: unknown): boolean { + return typeof body === 'object' && body !== null && 'default_auto_archive_duration' in body; +} + +function createApp(active: boolean): Hono { + const app = new Hono(); + app.onError(AppErrorHandler); + app.post('/control', Validator('json', Control), (ctx) => ctx.json(ctx.req.valid('json'))); + app.post('/gated', GatedJsonValidator(Control, Gated, {touchesGate, active: () => active}), (ctx) => + ctx.json(ctx.req.valid('json')), + ); + return app; +} + +async function post(app: Hono, path: string, body: string): Promise<{status: number; text: string}> { + const response = await app.request(path, {method: 'POST', headers: {'content-type': 'application/json'}, body}); + return {status: response.status, text: await response.text()}; +} + +describe('GatedJsonValidator', () => { + const bodies = [ + '{"name":"general","default_auto_archive_duration":60}', + '{"name":"general"}', + '{"name":"far too long for the control schema","default_auto_archive_duration":60}', + '{"name":', + '{"default_auto_archive_duration":"soon"}', + ]; + + it('behaves exactly like the control schema while inactive', async () => { + const app = createApp(false); + for (const body of bodies) { + expect(await post(app, '/gated', body)).toEqual(await post(app, '/control', body)); + } + }); + + it('keeps gated keys when the body touches the gate and the caller is active', async () => { + const app = createApp(true); + expect(await post(app, '/gated', bodies[0]!)).toEqual({ + status: 200, + text: '{"name":"general","default_auto_archive_duration":60}', + }); + expect((await post(app, '/gated', bodies[4]!)).status).toBe(400); + expect(await post(app, '/gated', bodies[1]!)).toEqual(await post(app, '/control', bodies[1]!)); + }); +}); diff --git a/fluxer_api/src/api/channel/utils/EffectiveContentWarning.ts b/fluxer_api/src/api/channel/utils/EffectiveContentWarning.ts index 59bd10f92..ee167d9d2 100644 --- a/fluxer_api/src/api/channel/utils/EffectiveContentWarning.ts +++ b/fluxer_api/src/api/channel/utils/EffectiveContentWarning.ts @@ -34,6 +34,14 @@ export function computeEffectiveChannelNsfw( return guild.nsfw; } +export function resolveEffectiveThreadNsfw( + parent: ContentWarningChannelLike, + parentCategory: ContentWarningChannelLike | null, + guild: ContentWarningGuildLike, +): boolean { + return computeEffectiveChannelNsfw(parent, parentCategory, guild); +} + export function computeEffectiveContentWarning( channel: ContentWarningChannelLike, parentCategory: ContentWarningChannelLike | null, diff --git a/fluxer_api/src/api/channel/utils/ThreadNsfwScope.ts b/fluxer_api/src/api/channel/utils/ThreadNsfwScope.ts new file mode 100644 index 000000000..318716419 --- /dev/null +++ b/fluxer_api/src/api/channel/utils/ThreadNsfwScope.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID} from '@app/api/BrandedTypes'; +import type {Channel} from '@app/api/models/Channel'; + +export async function resolveNsfwScopeChannel( + channel: Channel, + findChannel: (channelId: ChannelID) => Promise, +): Promise { + if (!channel.isThread() || channel.parentId === null) return channel; + return (await findChannel(channel.parentId)) ?? channel; +} diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index e87e2ed5f..2998de767 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -350,6 +350,7 @@ export interface APIConfig { laneName?: APIWorkerLaneName; taskName?: WorkerTaskName; enableCronScheduler?: boolean; + metricsPort?: number; laneConcurrencyOverrides: { realtime?: number; unfurl?: number; diff --git a/fluxer_api/src/api/constants/Gateway.ts b/fluxer_api/src/api/constants/Gateway.ts index dd400618d..51fad5045 100644 --- a/fluxer_api/src/api/constants/Gateway.ts +++ b/fluxer_api/src/api/constants/Gateway.ts @@ -41,6 +41,12 @@ export type GatewayDispatchEvent = | 'CHANNEL_PINS_ACK' | 'CHANNEL_RECIPIENT_ADD' | 'CHANNEL_RECIPIENT_REMOVE' + | 'THREAD_CREATE' + | 'THREAD_UPDATE' + | 'THREAD_DELETE' + | 'THREAD_LIST_SYNC' + | 'THREAD_MEMBER_UPDATE' + | 'THREAD_MEMBERS_UPDATE' | 'MESSAGE_CREATE' | 'MESSAGE_UPDATE' | 'MESSAGE_DELETE' diff --git a/fluxer_api/src/api/csam/NcmecSubmissionService.ts b/fluxer_api/src/api/csam/NcmecSubmissionService.ts index c243bcf4e..e78321795 100644 --- a/fluxer_api/src/api/csam/NcmecSubmissionService.ts +++ b/fluxer_api/src/api/csam/NcmecSubmissionService.ts @@ -17,6 +17,7 @@ import { import {Config} from '@app/api/Config'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import type {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository'; +import {withThreadContext} from '@app/api/channel/services/ChannelGatewayDispatch'; import { enqueueCrosspostFamilyPurgeFromCopies, enqueueCrosspostSourceRemoval, @@ -24,6 +25,7 @@ import { import { attachmentStorageChannelId, collectMessageAttachments, + decrementThreadMessageCount, makeAttachmentCdnKey, makeAttachmentCdnUrl, purgeMessageAttachments, @@ -785,6 +787,7 @@ export class NcmecSubmissionService { message.authorId ?? fallbackUserId, message.pinnedTimestamp || undefined, ); + await decrementThreadMessageCount(this.deps.channelRepository, channel, [messageId]); await deleteMessageSearchDocuments([messageId], {context: {source: 'ncmec_submission_delete'}}); await enqueueCrosspostSourceRemoval(this.deps.workerService, {messages: [message], mode: 'purge'}); await enqueueCrosspostFamilyPurgeFromCopies(this.deps.workerService, {messages: [message]}); @@ -793,7 +796,7 @@ export class NcmecSubmissionService { await this.deps.gatewayService.dispatchGuild({ guildId: channel.guildId, event: 'MESSAGE_DELETE', - data: {channel_id: channelId.toString(), id: messageId.toString()}, + data: withThreadContext(channel, {channel_id: channelId.toString(), id: messageId.toString()}), }); return; } diff --git a/fluxer_api/src/api/database/types/ChannelTypes.ts b/fluxer_api/src/api/database/types/ChannelTypes.ts index 8676e6c87..ea4ee73a2 100644 --- a/fluxer_api/src/api/database/types/ChannelTypes.ts +++ b/fluxer_api/src/api/database/types/ChannelTypes.ts @@ -132,6 +132,8 @@ export interface ReadStateRow { message_id: Nullish; mention_count: number; last_pin_timestamp: Nullish; + flags?: Nullish; + guild_id?: Nullish; } export const CHANNEL_COLUMNS = [ @@ -224,6 +226,8 @@ export const READ_STATE_COLUMNS = [ 'message_id', 'mention_count', 'last_pin_timestamp', + 'flags', + 'guild_id', ] as const satisfies ReadonlyArray; export const PRIVATE_CHANNEL_COLUMNS = [ 'user_id', diff --git a/fluxer_api/src/api/database/types/ThreadTypes.ts b/fluxer_api/src/api/database/types/ThreadTypes.ts new file mode 100644 index 000000000..9fda4ac64 --- /dev/null +++ b/fluxer_api/src/api/database/types/ThreadTypes.ts @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; +import type {MuteConfig} from '@app/api/database/types/UserTypes'; + +type Nullish = T | null; + +export interface ForumTagUdt { + id: bigint; + name: string; + moderated: boolean; + emoji_id: Nullish; + emoji_name: Nullish; +} + +export interface ThreadStateRow { + thread_id: ChannelID; + guild_id: GuildID; + parent_id: ChannelID; + type: number; + archived: boolean; + locked: boolean; + invitable: Nullish; + auto_archive_duration: number; + archive_timestamp: Nullish; + created_at: Date; + flags: number; + applied_tags: Nullish>; + member_count: number; + member_ids_preview: Nullish>; + has_starter: boolean; + state_version: number; +} + +export const THREAD_STATE_COLUMNS = [ + 'thread_id', + 'guild_id', + 'parent_id', + 'type', + 'archived', + 'locked', + 'invitable', + 'auto_archive_duration', + 'archive_timestamp', + 'created_at', + 'flags', + 'applied_tags', + 'member_count', + 'member_ids_preview', + 'has_starter', + 'state_version', +] as const satisfies ReadonlyArray; + +export interface ThreadStatsRow { + thread_id: ChannelID; + message_count: Nullish; + total_message_sent: Nullish; +} + +export const THREAD_STATS_COLUMNS = [ + 'thread_id', + 'message_count', + 'total_message_sent', +] as const satisfies ReadonlyArray; + +export interface ThreadsByParentRow { + parent_id: ChannelID; + thread_id: ChannelID; + guild_id: GuildID; + type: number; +} + +export const THREADS_BY_PARENT_COLUMNS = [ + 'parent_id', + 'thread_id', + 'guild_id', + 'type', +] as const satisfies ReadonlyArray; + +export interface ActiveThreadsByGuildRow { + guild_id: GuildID; + thread_id: ChannelID; + parent_id: ChannelID; + type: number; +} + +export const ACTIVE_THREADS_BY_GUILD_COLUMNS = [ + 'guild_id', + 'thread_id', + 'parent_id', + 'type', +] as const satisfies ReadonlyArray; + +export interface ArchivedThreadsByParentRow { + parent_id: ChannelID; + is_private: boolean; + archive_timestamp: Date; + thread_id: ChannelID; + guild_id: GuildID; +} + +export const ARCHIVED_THREADS_BY_PARENT_COLUMNS = [ + 'parent_id', + 'is_private', + 'archive_timestamp', + 'thread_id', + 'guild_id', +] as const satisfies ReadonlyArray; + +export interface ThreadMemberRow { + thread_id: ChannelID; + user_id: UserID; + guild_id: GuildID; + parent_id: ChannelID; + join_timestamp: Date; + flags: number; + muted: boolean; + mute_config: Nullish; +} + +export const THREAD_MEMBER_COLUMNS = [ + 'thread_id', + 'user_id', + 'guild_id', + 'parent_id', + 'join_timestamp', + 'flags', + 'muted', + 'mute_config', +] as const satisfies ReadonlyArray; + +export interface ThreadMembersByUserRow { + user_id: UserID; + guild_id: GuildID; + parent_id: ChannelID; + is_private: boolean; + thread_id: ChannelID; +} + +export const THREAD_MEMBERS_BY_USER_COLUMNS = [ + 'user_id', + 'guild_id', + 'parent_id', + 'is_private', + 'thread_id', +] as const satisfies ReadonlyArray; + +export interface ThreadParentConfigRow { + guild_id: GuildID; + channel_id: ChannelID; + flags: Nullish; + default_auto_archive_duration: Nullish; + default_thread_rate_limit_per_user: Nullish; + available_tags: Nullish>; + default_reaction_emoji_id: Nullish; + default_reaction_emoji_name: Nullish; + default_sort_order: Nullish; + default_forum_layout: Nullish; + default_tag_setting: Nullish; + has_threads: Nullish; +} + +export const THREAD_PARENT_CONFIG_COLUMNS = [ + 'guild_id', + 'channel_id', + 'flags', + 'default_auto_archive_duration', + 'default_thread_rate_limit_per_user', + 'available_tags', + 'default_reaction_emoji_id', + 'default_reaction_emoji_name', + 'default_sort_order', + 'default_forum_layout', + 'default_tag_setting', + 'has_threads', +] as const satisfies ReadonlyArray; + +export interface ForumPinnedThreadRow { + parent_id: ChannelID; + thread_id: ChannelID; +} + +export const FORUM_PINNED_THREAD_COLUMNS = ['parent_id', 'thread_id'] as const satisfies ReadonlyArray< + keyof ForumPinnedThreadRow +>; + +export interface ThreadOnlyChannelsByGuildRow { + guild_id: GuildID; + channel_id: ChannelID; +} + +export const THREAD_ONLY_CHANNELS_BY_GUILD_COLUMNS = ['guild_id', 'channel_id'] as const satisfies ReadonlyArray< + keyof ThreadOnlyChannelsByGuildRow +>; + +export interface GuildThreadStateRow { + guild_id: GuildID; + first_active_at: Nullish; + perms_seeded_at: Nullish; + search_backfilled_at: Nullish; +} + +export const GUILD_THREAD_STATE_COLUMNS = [ + 'guild_id', + 'first_active_at', + 'perms_seeded_at', + 'search_backfilled_at', +] as const satisfies ReadonlyArray; diff --git a/fluxer_api/src/api/database/types/UserTypes.ts b/fluxer_api/src/api/database/types/UserTypes.ts index 9257b95a6..2a0e89569 100644 --- a/fluxer_api/src/api/database/types/UserTypes.ts +++ b/fluxer_api/src/api/database/types/UserTypes.ts @@ -305,6 +305,7 @@ export interface ChannelOverride { muted: boolean; mute_config: Nullish; unread_badges: Nullish; + flags?: Nullish; } export interface UserGuildSettingsRow { @@ -443,6 +444,7 @@ export interface PushSubscriptionRow { platform?: Nullish; app_id?: Nullish; provider_environment?: Nullish; + thread_channels?: Nullish; } export const PUSH_SUBSCRIPTION_COLUMNS = [ @@ -456,6 +458,7 @@ export const PUSH_SUBSCRIPTION_COLUMNS = [ 'platform', 'app_id', 'provider_environment', + 'thread_channels', ] as const satisfies ReadonlyArray; export interface UserContactChangeLogRow { diff --git a/fluxer_api/src/api/experiment/ChannelThreadsGate.ts b/fluxer_api/src/api/experiment/ChannelThreadsGate.ts new file mode 100644 index 000000000..16623d7d4 --- /dev/null +++ b/fluxer_api/src/api/experiment/ChannelThreadsGate.ts @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {GuildID, UserID} from '@app/api/BrandedTypes'; +import {executeConditional, fetchOne} from '@app/api/database/CassandraQueryExecution'; +import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes'; +import {GuildThreadState} from '@app/api/Tables'; +import type {HonoEnv} from '@app/api/types/HonoEnv'; +import {CHANNEL_THREADS_CLIENT_FEATURE} from '@app/api/utils/featureUtils'; +import { + type ChannelThreadsConfig, + type CompiledChannelThreadsConfig, + channelThreadsGuildActive, + channelThreadsUserActive, + channelThreadsUserExcluded, + compileChannelThreadsConfig, + DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {Context} from 'hono'; + +export { + THREAD_CHANNEL_TYPES, + THREAD_FEATURE_CHANNEL_TYPES, + THREAD_ONLY_CHANNEL_TYPES, + THREAD_PARENT_CHANNEL_TYPES, +} from '@fluxer/constants/src/ThreadConstants'; + +export type ThreadViewer = {kind: 'user'; userId: UserID; bot: boolean; capable: boolean} | {kind: 'system'}; + +export const SYSTEM_THREAD_VIEWER: ThreadViewer = {kind: 'system'}; + +const TAINT_CACHE_TTL_MS = 60_000; +const TAINT_CACHE_MAX_ENTRIES = 10_000; + +const FETCH_GUILD_THREAD_STATE_CQL = GuildThreadState.selectCql({ + columns: ['guild_id'], + where: GuildThreadState.where.eq('guild_id'), + limit: 1, +}); + +const taintCache = new Map(); + +let compiled: CompiledChannelThreadsConfig = DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG; +let compiledSource: string | null | undefined; + +export function syncChannelThreadsConfig( + raw: string | null, + parse: (raw: string | null) => ChannelThreadsConfig, +): CompiledChannelThreadsConfig { + if (raw === compiledSource) return compiled; + compiled = raw === null ? DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG : compileChannelThreadsConfig(parse(raw)); + compiledSource = raw; + return compiled; +} + +export function getCompiledChannelThreadsConfig(): CompiledChannelThreadsConfig { + return compiled; +} + +export function channelThreadsEnabled(): boolean { + return compiled.config.enabled; +} + +export function everEnabled(): boolean { + return compiled.config.ever_enabled; +} + +export function guildActive(guildId: GuildID | bigint | string): boolean { + return compiled.config.enabled && channelThreadsGuildActive(compiled, guildId.toString()); +} + +export function userActive(userId: UserID | bigint | string): boolean { + return compiled.config.enabled && channelThreadsUserActive(compiled, userId.toString()); +} + +export function userExcluded(userId: UserID | bigint | string): boolean { + return channelThreadsUserExcluded(compiled, userId.toString()); +} + +export function userViewerActive(viewer: ThreadViewer): boolean { + if (!compiled.config.enabled) return false; + if (viewer.kind === 'system') return true; + return viewer.bot ? !userExcluded(viewer.userId) : viewer.capable && userActive(viewer.userId); +} + +export function viewerActive(viewer: ThreadViewer, guildId: GuildID | bigint | string): boolean { + return userViewerActive(viewer) && guildActive(guildId); +} + +export function recipientActive(guildId: GuildID | bigint | string, userId: UserID, bot: boolean): boolean { + if (!guildActive(guildId)) return false; + return bot ? !userExcluded(userId) : userActive(userId); +} + +export function viewerFromCtx(ctx: Context): ThreadViewer { + const user = ctx.get('user'); + const bot = user.isBot; + const capable = + bot || (ctx.get('authTokenType') === 'session' && ctx.get('clientFeatures').has(CHANNEL_THREADS_CLIENT_FEATURE)); + return {kind: 'user', userId: user.id, bot, capable}; +} + +function rememberTaint(key: string, tainted: boolean): void { + taintCache.delete(key); + taintCache.set(key, {tainted, checkedAt: Date.now()}); + if (taintCache.size > TAINT_CACHE_MAX_ENTRIES) { + const oldest = taintCache.keys().next().value; + if (oldest !== undefined) taintCache.delete(oldest); + } +} + +export async function isTainted(guildId: GuildID, opts?: {fresh?: boolean}): Promise { + if (!everEnabled()) return false; + const key = guildId.toString(); + const cached = taintCache.get(key); + if ( + cached !== undefined && + (cached.tainted || opts?.fresh !== true) && + Date.now() - cached.checkedAt < TAINT_CACHE_TTL_MS + ) { + return cached.tainted; + } + const row = await fetchOne>(FETCH_GUILD_THREAD_STATE_CQL, {guild_id: guildId}); + const tainted = row !== null; + rememberTaint(key, tainted); + return tainted; +} + +export function noteGuildThreadMarker(guildId: GuildID): void { + rememberTaint(guildId.toString(), true); +} + +export async function insertGuildThreadMarker(guildId: GuildID, permsSeededAt: Date | null): Promise { + const created = await executeConditional( + GuildThreadState.insertIfNotExists({ + guild_id: guildId, + first_active_at: new Date(), + perms_seeded_at: permsSeededAt, + search_backfilled_at: null, + }), + ); + noteGuildThreadMarker(guildId); + return created; +} + +export async function ensureActiveGuildTainted(guildId: GuildID): Promise { + if (!guildActive(guildId) || (await isTainted(guildId))) return; + await insertGuildThreadMarker(guildId, null); +} + +export function clearChannelThreadsTaintCacheForTesting(): void { + taintCache.clear(); +} diff --git a/fluxer_api/src/api/experiment/ExperimentController.ts b/fluxer_api/src/api/experiment/ExperimentController.ts index f0bab5821..ea41621c4 100644 --- a/fluxer_api/src/api/experiment/ExperimentController.ts +++ b/fluxer_api/src/api/experiment/ExperimentController.ts @@ -9,6 +9,7 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig'; import type {HonoApp} from '@app/api/types/HonoEnv'; import {entityTagMatches} from '@app/api/utils/EntityTag'; import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers'; +import {resolveChannelThreadsAssignment} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas'; import {resolvePlutoniumPageAssignment} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas'; import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; @@ -30,9 +31,10 @@ export function ExperimentController(app: HonoApp) { }), async (ctx) => { const instanceConfigRepository = ctx.get('instanceConfigRepository'); - const [delivery, domainMigrationConfig, plutoniumPageConfig] = await Promise.all([ + const [delivery, domainMigrationConfig, channelThreadsConfig, plutoniumPageConfig] = await Promise.all([ instanceConfigRepository.getExperimentDeliveryConfig(), instanceConfigRepository.getDomainMigrationConfig(), + instanceConfigRepository.getCompiledChannelThreadsConfig(), instanceConfigRepository.getPlutoniumPageConfig(), ]); const user = ctx.get('user'); @@ -46,6 +48,10 @@ export function ExperimentController(app: HonoApp) { plutonium_page: resolvePlutoniumPageAssignment(plutoniumPageConfig, userId, targeting), }, }; + const channelThreads = resolveChannelThreadsAssignment(channelThreadsConfig, userId); + if (channelThreads !== undefined) { + body.assignments.channel_threads = channelThreads; + } const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`; ctx.header(HttpHeaders.ETAG, etag); ctx.header(HttpHeaders.CACHE_CONTROL, 'private, no-cache'); diff --git a/fluxer_api/src/api/experiment/tests/ChannelAuthViewerCoverage.test.ts b/fluxer_api/src/api/experiment/tests/ChannelAuthViewerCoverage.test.ts new file mode 100644 index 000000000..75ec21bea --- /dev/null +++ b/fluxer_api/src/api/experiment/tests/ChannelAuthViewerCoverage.test.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, expect, it} from 'vitest'; + +const API_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const CALL = '.getChannelAuthenticated('; + +const EXPECTED_CALL_SITES: Readonly> = { + 'channel/controllers/MessageInteractionController.ts': 1, + 'channel/services/AttachmentUploadService.ts': 2, + 'channel/services/ChannelDataService.ts': 2, + 'channel/services/ChannelRequestService.ts': 1, + 'channel/services/ChannelService.ts': 1, + 'channel/services/MessageInteractionService.ts': 10, + 'channel/services/channel_data/ChannelOperationsService.ts': 4, + 'channel/services/message/MessageDeleteService.ts': 3, + 'channel/services/message/MessageCrosspostService.ts': 1, + 'channel/services/message/MessageEditService.ts': 1, + 'channel/services/message/MessageRetrievalService.ts': 4, + 'channel/services/message/MessageSendService.ts': 5, + 'channel/services/thread/ThreadCreationService.ts': 3, + 'channel/services/thread/ThreadForumService.ts': 1, + 'channel/services/thread/ThreadListService.ts': 1, + 'channel/services/thread/ThreadMemberService.ts': 1, + 'channel/services/thread/ThreadMemberSettingsService.ts': 1, + 'favorite_meme/FavoriteMemeService.ts': 1, + 'report/ReportService.ts': 1, + 'user/services/UserContentService.ts': 1, + 'webhook/ChannelFollowService.ts': 2, +}; + +function listSourceFiles(directory: string): Array { + return fs.readdirSync(directory, {withFileTypes: true}).flatMap((entry) => { + const resolved = path.join(directory, entry.name); + if (entry.isDirectory()) return listSourceFiles(resolved); + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [resolved] : []; + }); +} + +function callArguments(source: string, openParen: number): string { + let depth = 0; + for (let index = openParen; index < source.length; index++) { + const char = source[index]; + if (char === '(') depth++; + if (char === ')') depth--; + if (depth === 0) return source.slice(openParen + 1, index); + } + throw new Error('unbalanced call'); +} + +function collectCallSites(): Map> { + const sites = new Map>(); + for (const file of listSourceFiles(API_ROOT)) { + const source = fs.readFileSync(file, 'utf8'); + let index = source.indexOf(CALL); + while (index !== -1) { + const relative = path.relative(API_ROOT, file).split(path.sep).join('/'); + const args = callArguments(source, index + CALL.length - 1); + sites.set(relative, [...(sites.get(relative) ?? []), args]); + index = source.indexOf(CALL, index + CALL.length); + } + } + return sites; +} + +describe('getChannelAuthenticated viewer coverage', () => { + const sites = collectCallSites(); + + it('finds exactly the allowlisted call sites', () => { + const counts = Object.fromEntries([...sites].map(([file, calls]) => [file, calls.length])); + expect(counts).toEqual(EXPECTED_CALL_SITES); + }); + + it('passes a thread viewer at every call site', () => { + const missing = [...sites].flatMap(([file, calls]) => + calls.filter((args) => !/\bviewer\b/.test(args)).map((args) => `${file}: ${args.replace(/\s+/g, ' ')}`), + ); + expect(missing).toEqual([]); + }); +}); diff --git a/fluxer_api/src/api/experiment/tests/ChannelLoadGateCoverage.test.ts b/fluxer_api/src/api/experiment/tests/ChannelLoadGateCoverage.test.ts new file mode 100644 index 000000000..0b0889ef8 --- /dev/null +++ b/fluxer_api/src/api/experiment/tests/ChannelLoadGateCoverage.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, expect, it} from 'vitest'; + +const API_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const DIRECT_LOAD = + /(?:channelRepository|ChannelRepository\(\)|ChannelDataRepository\(\)|channelRepo|channelDataRepository|channelData|get\('channelRepository'\))\.findUnique\(/g; +const GATE_REFERENCE = /ChannelThreadsGate|\bisThread\w*\(|THREAD_\w*CHANNEL_TYPES/; + +type Coverage = 'gated' | 'dm_or_voice_only' | 'admin_or_system'; + +const EXPECTED_LOADS: Readonly> = { + 'admin/controllers/ThreadAdminController.ts': [1, 'admin_or_system'], + 'admin/services/AdminAuditService.ts': [1, 'admin_or_system'], + 'admin/services/AdminMessageService.ts': [4, 'admin_or_system'], + 'admin/services/AdminReportService.ts': [4, 'gated'], + 'channel/ChannelRepository.ts': [1, 'admin_or_system'], + 'channel/services/AttachmentUploadService.ts': [1, 'gated'], + 'channel/services/BaseChannelAuthService.ts': [4, 'gated'], + 'channel/services/CallService.ts': [5, 'dm_or_voice_only'], + 'channel/services/PersonalNotesChannelRepair.ts': [1, 'dm_or_voice_only'], + 'channel/services/channel_data/ChannelOperationsService.ts': [5, 'gated'], + 'channel/services/channel_data/GroupDmUpdateService.ts': [1, 'dm_or_voice_only'], + 'channel/services/group_dm/GroupDmOperationsService.ts': [4, 'dm_or_voice_only'], + 'channel/services/message/CrosspostDeliveryService.ts': [8, 'admin_or_system'], + 'channel/services/message/MessageDeleteService.ts': [1, 'gated'], + 'channel/services/message/MessagePersistenceService.ts': [1, 'admin_or_system'], + 'channel/services/message/MessageProcessingService.ts': [1, 'admin_or_system'], + 'channel/services/message/MessageRequestParser.ts': [1, 'gated'], + 'channel/services/message/MessageRetrievalService.ts': [1, 'gated'], + 'channel/services/message/MessageSendService.ts': [2, 'gated'], + 'channel/services/message/MessageSystemService.ts': [1, 'admin_or_system'], + 'channel/services/message/ThreadMessageActivity.ts': [1, 'admin_or_system'], + 'channel/services/message/UserMessageDeletionService.ts': [2, 'admin_or_system'], + 'channel/services/thread/ThreadCreationService.ts': [4, 'gated'], + 'channel/services/thread/ThreadDeletionService.ts': [1, 'admin_or_system'], + 'csam/NcmecSubmissionService.ts': [1, 'admin_or_system'], + 'guild/services/channel/ChannelOperationsService.ts': [2, 'gated'], + 'guild/services/GuildDiscoveryService.ts': [1, 'gated'], + 'guild/services/data/GuildOperationsService.ts': [2, 'gated'], + 'middleware/GuildAvailabilityMiddleware.ts': [1, 'admin_or_system'], + 'report/ReportService.ts': [3, 'gated'], + 'rpc/RpcService.ts': [3, 'gated'], + 'search/GlobalSearchService.ts': [3, 'gated'], + 'search/MessageSearchResponseMapper.ts': [1, 'gated'], + 'user/entrance_sound/EntranceSoundPlayService.ts': [1, 'dm_or_voice_only'], + 'voice/VoiceService.ts': [1, 'dm_or_voice_only'], + 'webhook/ChannelFollowService.ts': [2, 'gated'], + 'webhook/WebhookModel.ts': [1, 'admin_or_system'], + 'webhook/WebhookRequestService.ts': [1, 'gated'], + 'webhook/WebhookService.ts': [7, 'gated'], + 'worker/tasks/ExtractEmbeds.ts': [1, 'admin_or_system'], + 'worker/tasks/HandleMentions.ts': [1, 'admin_or_system'], + 'worker/tasks/HarvestUserData.ts': [1, 'gated'], + 'worker/tasks/IndexChannelMessages.ts': [1, 'admin_or_system'], + 'worker/tasks/RemoveChannelFollowers.ts': [1, 'admin_or_system'], + 'worker/tasks/ThreadMentionScope.ts': [1, 'gated'], + 'worker/tasks/utils/MessageDeletion.ts': [1, 'admin_or_system'], +}; + +function listSourceFiles(directory: string): Array { + return fs.readdirSync(directory, {withFileTypes: true}).flatMap((entry) => { + const resolved = path.join(directory, entry.name); + if (entry.isDirectory()) return entry.name === 'tests' ? [] : listSourceFiles(resolved); + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [resolved] : []; + }); +} + +function collectLoads(): Map { + const loads = new Map(); + for (const file of listSourceFiles(API_ROOT)) { + const relative = path.relative(API_ROOT, file).split(path.sep).join('/'); + if (relative.startsWith('test/')) continue; + const source = fs.readFileSync(file, 'utf8'); + const count = source.match(DIRECT_LOAD)?.length ?? 0; + if (count > 0) loads.set(relative, {count, source}); + } + return loads; +} + +describe('direct channel load gate coverage', () => { + const loads = collectLoads(); + + it('finds exactly the allowlisted direct channel loads', () => { + const counts = Object.fromEntries([...loads].map(([file, {count}]) => [file, count])); + const expected = Object.fromEntries(Object.entries(EXPECTED_LOADS).map(([file, [count]]) => [file, count])); + expect(counts).toEqual(expected); + }); + + it('references the thread gate in every file marked gated', () => { + const ungated = Object.entries(EXPECTED_LOADS) + .filter(([file, [, coverage]]) => coverage === 'gated' && !GATE_REFERENCE.test(loads.get(file)?.source ?? '')) + .map(([file]) => file); + expect(ungated).toEqual([]); + }); +}); diff --git a/fluxer_api/src/api/experiment/tests/ChannelThreadsGate.test.ts b/fluxer_api/src/api/experiment/tests/ChannelThreadsGate.test.ts new file mode 100644 index 000000000..83b1fab29 --- /dev/null +++ b/fluxer_api/src/api/experiment/tests/ChannelThreadsGate.test.ts @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createGuildID, createUserID} from '@app/api/BrandedTypes'; +import {resolveThreadGateRpcData} from '@app/api/channel/services/thread/ThreadRpcData'; +import {setCassandraQueryExecutorForTesting, upsertOne} from '@app/api/database/CassandraQueryExecution'; +import type {CassandraParams, PreparedQuery} from '@app/api/database/CassandraTypes'; +import { + clearChannelThreadsTaintCacheForTesting, + guildActive, + isTainted, + noteGuildThreadMarker, + recipientActive, + SYSTEM_THREAD_VIEWER, + syncChannelThreadsConfig, + type ThreadViewer, + userActive, + userViewerActive, + viewerActive, +} from '@app/api/experiment/ChannelThreadsGate'; +import {GuildRepository} from '@app/api/guild/repositories/GuildRepository'; +import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry'; +import {GuildThreadState} from '@app/api/Tables'; +import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; +import {NoopWorkerService} from '@app/api/test/NoopWorkerService'; +import { + type ChannelThreadsConfig, + ChannelThreadsConfigSchema, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +class CountingExecutor extends InMemoryCassandraQueryExecutor { + reads = 0; + markerReads = 0; + + override async executeQuery, P extends CassandraParams = CassandraParams>( + query: PreparedQuery

, + ): Promise> { + if (query.cql.trimStart().toUpperCase().startsWith('SELECT')) this.reads++; + if (query.cql.includes('guild_thread_state')) this.markerReads++; + return super.executeQuery(query); + } +} + +const GUILD = createGuildID(100n); +const OTHER_GUILD = createGuildID(200n); +const USER = createUserID(10n); +const OTHER_USER = createUserID(20n); + +function load(config: Partial): void { + const raw = JSON.stringify(ChannelThreadsConfigSchema.parse(config)); + syncChannelThreadsConfig(raw, (value) => ChannelThreadsConfigSchema.parse(JSON.parse(value ?? '{}'))); +} + +function userViewer(overrides: Partial> = {}): ThreadViewer { + return {kind: 'user', userId: USER, bot: false, capable: true, ...overrides}; +} + +describe('ChannelThreadsGate', () => { + let executor: CountingExecutor; + + beforeEach(() => { + executor = new CountingExecutor(); + setCassandraQueryExecutorForTesting(executor); + clearChannelThreadsTaintCacheForTesting(); + }); + + afterEach(() => { + syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({})); + setCassandraQueryExecutorForTesting(null); + }); + + it('is off for everyone while the config is missing', () => { + syncChannelThreadsConfig(null, () => { + throw new Error('a missing config is never parsed'); + }); + expect(guildActive(GUILD)).toBe(false); + expect(userActive(USER)).toBe(false); + expect(viewerActive(SYSTEM_THREAD_VIEWER, GUILD)).toBe(false); + }); + + it('parses a snapshot once and reuses the compiled value while the string is unchanged', () => { + const raw = JSON.stringify(ChannelThreadsConfigSchema.parse({enabled: true, enabled_guild_ids: ['100']})); + let parses = 0; + const parse = (value: string | null) => { + parses++; + return ChannelThreadsConfigSchema.parse(JSON.parse(value ?? '{}')); + }; + const first = syncChannelThreadsConfig(raw, parse); + expect(syncChannelThreadsConfig(raw, parse)).toBe(first); + expect(parses).toBe(1); + expect(guildActive(GUILD)).toBe(true); + }); + + it('requires the guild, the user and the client capability for a user viewer', () => { + load({enabled: true, enabled_guild_ids: ['100'], included_user_ids: ['10']}); + expect(viewerActive(userViewer(), GUILD)).toBe(true); + expect(viewerActive(userViewer(), OTHER_GUILD)).toBe(false); + expect(viewerActive(userViewer({capable: false}), GUILD)).toBe(false); + expect(viewerActive(userViewer({userId: OTHER_USER}), GUILD)).toBe(false); + }); + + it('lets bots follow the guild gate unless they are excluded', () => { + load({enabled: true, enabled_guild_ids: ['100']}); + const bot = userViewer({userId: OTHER_USER, bot: true, capable: true}); + expect(viewerActive(bot, GUILD)).toBe(true); + expect(recipientActive(GUILD, OTHER_USER, true)).toBe(true); + load({enabled: true, enabled_guild_ids: ['100'], excluded_user_ids: ['20']}); + expect(viewerActive(bot, GUILD)).toBe(false); + expect(recipientActive(GUILD, OTHER_USER, true)).toBe(false); + }); + + it('puts exclusion ahead of inclusion and the kill switch ahead of everything', () => { + load({enabled: true, enabled_guild_ids: ['100'], disabled_guild_ids: ['100'], user_basis_points: 10000}); + expect(guildActive(GUILD)).toBe(false); + load({enabled: false, enabled_guild_ids: ['100'], included_user_ids: ['10'], ever_enabled: true}); + expect(viewerActive(SYSTEM_THREAD_VIEWER, GUILD)).toBe(false); + expect(userViewerActive(userViewer())).toBe(false); + }); + + it('ignores capability for per-user fan-out', () => { + load({enabled: true, enabled_guild_ids: ['100'], included_user_ids: ['10']}); + expect(recipientActive(GUILD, USER, false)).toBe(true); + expect(recipientActive(GUILD, OTHER_USER, false)).toBe(false); + }); + + it('reads no marker while the experiment has never been enabled', async () => { + load({enabled: false, ever_enabled: false}); + expect(await isTainted(GUILD)).toBe(false); + expect(executor.reads).toBe(0); + }); + + it('reads the marker once per guild and remembers the answer', async () => { + load({enabled: false, ever_enabled: true}); + await upsertOne( + GuildThreadState.upsertAll({ + guild_id: GUILD, + first_active_at: new Date(), + perms_seeded_at: null, + search_backfilled_at: null, + }), + ); + expect(await isTainted(GUILD)).toBe(true); + expect(await isTainted(GUILD)).toBe(true); + expect(await isTainted(OTHER_GUILD)).toBe(false); + expect(executor.reads).toBe(2); + noteGuildThreadMarker(OTHER_GUILD); + expect(await isTainted(OTHER_GUILD)).toBe(true); + expect(executor.reads).toBe(2); + }); + + it('remembers a clean answer across gate-off guild collection loads', async () => { + load({enabled: false, ever_enabled: true}); + expect(await resolveThreadGateRpcData(GUILD)).toBeNull(); + expect(await resolveThreadGateRpcData(GUILD)).toBeNull(); + expect(executor.markerReads).toBe(1); + }); + + it('rereads a remembered clean answer for fresh checks and trusts a remembered taint', async () => { + load({enabled: false, ever_enabled: true}); + expect(await isTainted(GUILD)).toBe(false); + await upsertOne( + GuildThreadState.upsertAll({ + guild_id: GUILD, + first_active_at: new Date(), + perms_seeded_at: null, + search_backfilled_at: null, + }), + ); + expect(await isTainted(GUILD)).toBe(false); + expect(executor.reads).toBe(1); + expect(await isTainted(GUILD, {fresh: true})).toBe(true); + expect(executor.reads).toBe(2); + expect(await isTainted(GUILD, {fresh: true})).toBe(true); + expect(await isTainted(GUILD)).toBe(true); + expect(executor.reads).toBe(2); + }); + + it('lets member removal skip the marker read while the experiment has never been enabled', async () => { + load({enabled: false, ever_enabled: false}); + const repository = new GuildRepository(); + await repository.deleteMember(GUILD, USER); + expect(executor.markerReads).toBe(0); + }); + + it('enqueues thread membership cleanup on member removal despite a remembered clean answer', async () => { + load({enabled: false, ever_enabled: true}); + const worker = new NoopWorkerService(); + const addJob = vi.spyOn(worker, 'addJob'); + setInjectedWorkerService(worker); + try { + expect(await isTainted(GUILD)).toBe(false); + await upsertOne( + GuildThreadState.upsertAll({ + guild_id: GUILD, + first_active_at: new Date(), + perms_seeded_at: null, + search_backfilled_at: null, + }), + ); + await new GuildRepository().deleteMember(GUILD, USER); + expect(addJob).toHaveBeenCalledWith( + 'removeThreadMembershipsForGuildMember', + {guildId: GUILD.toString(), userId: USER.toString()}, + {jobKey: `remove-thread-memberships-${GUILD}-${USER}`}, + ); + } finally { + setInjectedWorkerService(undefined); + } + }); +}); diff --git a/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts b/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts index 1a9084bcd..9befbec71 100644 --- a/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts +++ b/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts @@ -2,6 +2,7 @@ import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils'; import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils'; +import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher'; import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; import {HTTP_STATUS} from '@app/api/test/TestConstants'; @@ -9,6 +10,10 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest import {grantPremium} from '@app/api/user/tests/UserTestUtils'; import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import { + applyChannelThreadsConfigUpdate, + type ChannelThreadsConfig, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; import { DEFAULT_DOMAIN_MIGRATION_CONFIG, INERT_DOMAIN_MIGRATION_ASSIGNMENT, @@ -22,10 +27,11 @@ import { DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT, type ExperimentAssignmentsResponse, type ExperimentDeliveryConfigResponse, + readChannelThreadsAssignment, readDomainMigrationAssignment, readPlutoniumPageAssignment, } from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; const NOT_MODIFIED = 304; const ENDPOINT = '/experiments'; @@ -41,6 +47,10 @@ describe('GET /experiments', () => { await harness.reset(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + afterAll(async () => { await harness.shutdown(); }); @@ -455,4 +465,68 @@ describe('GET /experiments', () => { expect(body.poll_interval_seconds).toBe(3600); expect(body.poll_jitter_percent).toBe(DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT); }); + + it('keeps the control body and etag for a user outside the channel threads experiment', async () => { + const account = await createTestAccount(harness); + const before = await createBuilder(harness, account.token) + .get(ENDPOINT) + .executeWithResponse(); + + await getInstanceConfigRepository().updateChannelThreadsConfig((current) => + applyChannelThreadsConfigUpdate(current, {enabled: true, included_user_ids: ['1']}), + ); + const after = await createBuilder(harness, account.token) + .get(ENDPOINT) + .executeWithResponse(); + + expect(Object.hasOwn(after.json.assignments, 'channel_threads')).toBe(false); + expect(readChannelThreadsAssignment(after.json)).toBeNull(); + expect(after.response.headers.get('etag')).toBe(before.response.headers.get('etag')); + }); + + it('assigns channel threads to an included user with the config version', async () => { + const account = await createTestAccount(harness); + await getInstanceConfigRepository().updateChannelThreadsConfig((current) => + applyChannelThreadsConfigUpdate(current, {enabled: true, included_user_ids: [account.userId]}), + ); + + const body = await createBuilder(harness, account.token).get(ENDPOINT).execute(); + + expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 1}); + }); + + it('bumps the channel threads version, keeps ever_enabled sticky and publishes every admin update', async () => { + const publish = vi.spyOn(ChannelThreadsConfigPublisher.prototype, 'publish').mockResolvedValue(undefined); + const admin = await setUserACLs(harness, await createTestAccount(harness), [ + AdminACLs.AUTHENTICATE, + AdminACLs.INSTANCE_CONFIG_VIEW, + AdminACLs.INSTANCE_CONFIG_UPDATE, + ]); + const patch = (body: Record) => + createBuilder<{channel_threads: ChannelThreadsConfig}>(harness, admin.token) + .patch('/admin/instance/config') + .body({channel_threads: body}) + .execute(); + + const initial = await createBuilder<{channel_threads: ChannelThreadsConfig}>(harness, admin.token) + .get('/admin/instance/config') + .execute(); + expect(initial.channel_threads).toMatchObject({enabled: false, config_version: 0, ever_enabled: false}); + + const enabled = await patch({enabled: true, included_user_ids: [admin.userId], ever_enabled: false}); + expect(enabled.channel_threads).toMatchObject({enabled: true, config_version: 1, ever_enabled: true}); + + const disabled = await patch({enabled: false, config_version: 99}); + expect(disabled.channel_threads).toMatchObject({enabled: false, config_version: 2, ever_enabled: true}); + + const unchanged = await patch({}); + expect(unchanged.channel_threads.config_version).toBe(2); + + expect(publish.mock.calls.map(([config]) => [config.config_version, config.enabled])).toEqual([ + [1, true], + [2, false], + ]); + const body = await createBuilder(harness, admin.token).get(ENDPOINT).execute(); + expect(readChannelThreadsAssignment(body)).toBeNull(); + }); }); diff --git a/fluxer_api/src/api/experiment/tests/TextTypeSetConsumers.test.ts b/fluxer_api/src/api/experiment/tests/TextTypeSetConsumers.test.ts new file mode 100644 index 000000000..08bdaf8f3 --- /dev/null +++ b/fluxer_api/src/api/experiment/tests/TextTypeSetConsumers.test.ts @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, expect, it} from 'vitest'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..'); +const SCANNED_ROOTS = ['fluxer_api/src', 'fluxer_app/src', 'packages']; +const SET_USE = /\b(GUILD_TEXT_BASED_CHANNEL_TYPES|TEXT_BASED_CHANNEL_TYPES)\b/g; +const IMPORT_STATEMENT = /^import\s[\s\S]*?from\s+'[^']+';$/gm; + +const UNCHANGED = 'unchanged: threads and forums never reach this site or are handled by their own path'; +const DEFINITION = 'definition: the sets never gain 11/12/15/16 (R13)'; +const API_45 = 'API-45: threads resolve NSFW through resolveEffectiveThreadNsfw'; +const THREAD_TYPES_SEPARATE = 'thread types accepted separately through THREAD_CHANNEL_TYPES, 15/16 stay rejected'; +const WEBHOOK_TARGETS = 'webhook targets for 15/16 are resolved by API-8/9, threads are never webhook owners'; +const HARVEST = 'WK-6: harvest adds THREAD_CHANNEL_TYPES next to the set'; +const UPLOAD_TARGETS = + 'API-10: uploads accept THREAD_FEATURE_CHANNEL_TYPES next to the set, reached only past the viewer gate'; +const FORUM_RATE_LIMIT = 'M8: forum and media rate limits are accepted next to the set'; + +const EXPECTED_SITES: Readonly> = { + 'fluxer_api/src/api/channel/services/AttachmentUploadService.ts TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: UPLOAD_TARGETS, + }, + 'fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: FORUM_RATE_LIMIT, + }, + 'fluxer_api/src/api/channel/services/interaction/MessageInteractionBase.ts TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: THREAD_TYPES_SEPARATE, + }, + 'fluxer_api/src/api/channel/services/message/MessageContentService.ts GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: API_45, + }, + 'fluxer_api/src/api/channel/services/message/MessageValidationService.ts TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: THREAD_TYPES_SEPARATE, + }, + 'fluxer_api/src/api/webhook/WebhookService.ts GUILD_TEXT_BASED_CHANNEL_TYPES': {count: 1, decision: WEBHOOK_TARGETS}, + 'fluxer_api/src/api/worker/tasks/HarvestGuildData.ts GUILD_TEXT_BASED_CHANNEL_TYPES': {count: 1, decision: HARVEST}, + 'fluxer_app/src/features/app/components/dialogs/components/plutonium/hooks/useCommunityActions.tsx GUILD_TEXT_BASED_CHANNEL_TYPES': + {count: 1, decision: UNCHANGED}, + 'fluxer_app/src/features/app/components/layout/GuildLayout.tsx GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: UNCHANGED, + }, + 'fluxer_app/src/features/channel/components/message_search_bar/useMessageSearchAutocomplete.ts GUILD_TEXT_BASED_CHANNEL_TYPES': + {count: 1, decision: UNCHANGED}, + 'fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelOverviewTab.tsx GUILD_TEXT_BASED_CHANNEL_TYPES': + { + count: 2, + decision: UNCHANGED, + }, + 'fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelWebhooksTab.tsx GUILD_TEXT_BASED_CHANNEL_TYPES': + { + count: 2, + decision: WEBHOOK_TARGETS, + }, + 'fluxer_app/src/features/channel/models/Channel.ts GUILD_TEXT_BASED_CHANNEL_TYPES': {count: 1, decision: UNCHANGED}, + 'fluxer_app/src/features/channel/state/Channels.ts TEXT_BASED_CHANNEL_TYPES': {count: 2, decision: UNCHANGED}, + 'fluxer_app/src/features/channel/utils/ChannelCreateModalUtils.ts GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 2, + decision: UNCHANGED, + }, + 'fluxer_app/src/features/guild/components/modals/guild_tabs/GuildWebhooksTab.tsx GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: WEBHOOK_TARGETS, + }, + 'fluxer_app/src/features/guild/state/GuildReadState.ts GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 2, + decision: UNCHANGED, + }, + 'fluxer_app/src/features/invite/utils/InviteUtils.ts GUILD_TEXT_BASED_CHANNEL_TYPES': {count: 2, decision: UNCHANGED}, + 'fluxer_app/src/features/messaging/utils/ChannelShared.ts GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: UNCHANGED, + }, + 'fluxer_app/src/features/read_state/state/ReadStates.ts GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 2, + decision: UNCHANGED, + }, + 'fluxer_app/src/features/read_state/state/ReadStates.ts TEXT_BASED_CHANNEL_TYPES': {count: 3, decision: UNCHANGED}, + 'fluxer_app/src/features/search/components/search/ChannelFilterSheet.tsx GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: UNCHANGED, + }, + 'fluxer_app/src/features/ui/action_menu/ChannelContextMenu.tsx GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: UNCHANGED, + }, + 'fluxer_app/src/features/ui/action_menu/items/ChannelMenuItems.tsx GUILD_TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: UNCHANGED, + }, + 'fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx TEXT_BASED_CHANNEL_TYPES': { + count: 1, + decision: UNCHANGED, + }, + 'packages/constants/src/ChannelConstants.ts GUILD_TEXT_BASED_CHANNEL_TYPES': {count: 2, decision: DEFINITION}, + 'packages/constants/src/ChannelConstants.ts TEXT_BASED_CHANNEL_TYPES': {count: 1, decision: DEFINITION}, +}; + +function listSourceFiles(directory: string): Array { + return fs.readdirSync(directory, {withFileTypes: true}).flatMap((entry) => { + const resolved = path.join(directory, entry.name); + if (entry.isDirectory()) { + return entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'tests' + ? [] + : listSourceFiles(resolved); + } + if (entry.name.endsWith('.test.ts') || entry.name.endsWith('.test.tsx')) return []; + return entry.name.endsWith('.ts') || entry.name.endsWith('.tsx') ? [resolved] : []; + }); +} + +function collectSites(): Record { + const counts: Record = {}; + for (const root of SCANNED_ROOTS) { + for (const file of listSourceFiles(path.join(REPO_ROOT, root))) { + const source = fs.readFileSync(file, 'utf8').replace(IMPORT_STATEMENT, ''); + const relative = path.relative(REPO_ROOT, file).split(path.sep).join('/'); + for (const match of source.matchAll(SET_USE)) { + const key = `${relative} ${match[1]}`; + counts[key] = (counts[key] ?? 0) + 1; + } + } + } + return counts; +} + +describe('text type set consumers', () => { + it('lists every consumer of the text type sets with a decision', () => { + const expected = Object.fromEntries(Object.entries(EXPECTED_SITES).map(([key, site]) => [key, site.count])); + expect(collectSites()).toEqual(expected); + }); +}); diff --git a/fluxer_api/src/api/favorite_meme/FavoriteMemeController.ts b/fluxer_api/src/api/favorite_meme/FavoriteMemeController.ts index e3ba1e6c1..f2fbb768f 100644 --- a/fluxer_api/src/api/favorite_meme/FavoriteMemeController.ts +++ b/fluxer_api/src/api/favorite_meme/FavoriteMemeController.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createChannelID, createMemeID, createMessageID} from '@app/api/BrandedTypes'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; @@ -81,12 +82,9 @@ export function FavoriteMemeController(app: HonoApp) { const user = ctx.get('user'); const channelId = createChannelID(ctx.req.valid('param').channel_id); const messageId = createMessageID(ctx.req.valid('param').message_id); - const meme = await ctx.get('favoriteMemeRequestService').createFromMessage({ - user, - channelId, - messageId, - data: ctx.req.valid('json'), - }); + const meme = await ctx + .get('favoriteMemeRequestService') + .createFromMessage({viewer: viewerFromCtx(ctx), user, channelId, messageId, data: ctx.req.valid('json')}); return ctx.json(meme, 201); }, ); diff --git a/fluxer_api/src/api/favorite_meme/FavoriteMemeRequestService.ts b/fluxer_api/src/api/favorite_meme/FavoriteMemeRequestService.ts index ab4b5b652..815453169 100644 --- a/fluxer_api/src/api/favorite_meme/FavoriteMemeRequestService.ts +++ b/fluxer_api/src/api/favorite_meme/FavoriteMemeRequestService.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {ChannelID, MemeID, MessageID, UserID} from '@app/api/BrandedTypes'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import {mapFavoriteMemeToResponse} from '@app/api/favorite_meme/FavoriteMemeModel'; import type {FavoriteMemeService} from '@app/api/favorite_meme/FavoriteMemeService'; import type {User} from '@app/api/models/User'; @@ -23,6 +24,7 @@ interface FavoriteMemeCreateFromUrlParams { } interface FavoriteMemeCreateFromMessageParams { + viewer: ThreadViewer; user: User; channelId: ChannelID; messageId: MessageID; @@ -69,9 +71,10 @@ export class FavoriteMemeRequestService { } async createFromMessage(params: FavoriteMemeCreateFromMessageParams): Promise { - const {user, channelId, messageId, data} = params; + const {user, viewer, channelId, messageId, data} = params; const meme = await this.favoriteMemeService.createFromMessage({ user, + viewer, channelId, messageId, attachmentId: data.attachment_id?.toString(), diff --git a/fluxer_api/src/api/favorite_meme/FavoriteMemeService.ts b/fluxer_api/src/api/favorite_meme/FavoriteMemeService.ts index cf6207d25..2c3a433c5 100644 --- a/fluxer_api/src/api/favorite_meme/FavoriteMemeService.ts +++ b/fluxer_api/src/api/favorite_meme/FavoriteMemeService.ts @@ -7,6 +7,7 @@ import {createAttachmentID, createMemeID, userIdToChannelId} from '@app/api/Bran import {Config} from '@app/api/Config'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; import {attachmentStorageChannelId, makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import {mapFavoriteMemeToResponse} from '@app/api/favorite_meme/FavoriteMemeModel'; import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository'; import { @@ -105,6 +106,7 @@ export class FavoriteMemeService { async createFromMessage({ user, + viewer, channelId, messageId, attachmentId, @@ -114,6 +116,7 @@ export class FavoriteMemeService { tags, }: { user: User; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; attachmentId?: string; @@ -128,8 +131,13 @@ export class FavoriteMemeService { if (count >= maxMemes) { throw new MaxFavoriteMemesError(maxMemes); } - await this.channelService.channelData.auth.getChannelAuthenticated({userId: user.id, channelId}); - const message = await this.channelService.messages.retrieval.getMessage({userId: user.id, channelId, messageId}); + await this.channelService.channelData.auth.getChannelAuthenticated({userId: user.id, channelId, viewer}); + const message = await this.channelService.messages.retrieval.getMessage({ + userId: user.id, + viewer, + channelId, + messageId, + }); if (!message) { throw new UnknownMessageError(); } diff --git a/fluxer_api/src/api/guild/GuildAuditLogEntryMapper.ts b/fluxer_api/src/api/guild/GuildAuditLogEntryMapper.ts index 7b7df043b..a40db4122 100644 --- a/fluxer_api/src/api/guild/GuildAuditLogEntryMapper.ts +++ b/fluxer_api/src/api/guild/GuildAuditLogEntryMapper.ts @@ -2,8 +2,10 @@ import {createUserID, type UserID} from '@app/api/BrandedTypes'; import type {AuditLogChange, GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes'; -import type {GuildAuditLog} from '@app/api/models/GuildAuditLog'; +import {GuildAuditLog} from '@app/api/models/GuildAuditLog'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; +import {THREAD_FEATURE_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; +import {THREAD_PERMISSIONS, ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; import type {AuditLogOptions, GuildAuditLogEntryResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; import {isValidSnowflake} from '@fluxer/snowflake/src/Snowflake'; @@ -69,6 +71,126 @@ const OVERWRITE_ACTIONS: ReadonlySet = new Set([ AuditLogActionType.CHANNEL_OVERWRITE_DELETE, ]); +export const THREAD_SCOPED_AUDIT_OPTION = 'thread_scoped'; + +export const THREAD_AUDIT_LOG_ACTION_TYPES: ReadonlySet = new Set([ + AuditLogActionType.THREAD_CREATE, + AuditLogActionType.THREAD_UPDATE, + AuditLogActionType.THREAD_DELETE, +]); + +const CHANNEL_TARGET_ACTIONS: ReadonlySet = new Set([ + AuditLogActionType.CHANNEL_CREATE, + AuditLogActionType.CHANNEL_UPDATE, + AuditLogActionType.CHANNEL_DELETE, + ...OVERWRITE_ACTIONS, +]); + +const THREAD_FEATURE_CHANNEL_TYPE_STRINGS: ReadonlySet = new Set( + [...THREAD_FEATURE_CHANNEL_TYPES].map((type) => type.toString()), +); + +export function collectAuditLogChannelIds(log: GuildAuditLog): Array { + const ids: Array = []; + if (CHANNEL_TARGET_ACTIONS.has(log.actionType) && log.targetId) ids.push(log.targetId); + const channelId = log.options.get('channel_id'); + if (channelId) ids.push(channelId); + return ids; +} + +export function isThreadScopedAuditLog(log: GuildAuditLog, gatedChannelIds: ReadonlySet): boolean { + if (THREAD_AUDIT_LOG_ACTION_TYPES.has(log.actionType) || log.options.has(THREAD_SCOPED_AUDIT_OPTION)) return true; + if ( + (log.actionType === AuditLogActionType.CHANNEL_CREATE || + log.actionType === AuditLogActionType.CHANNEL_UPDATE || + log.actionType === AuditLogActionType.CHANNEL_DELETE) && + THREAD_FEATURE_CHANNEL_TYPE_STRINGS.has(log.options.get('type') ?? '') + ) { + return true; + } + return collectAuditLogChannelIds(log).some((id) => gatedChannelIds.has(id)); +} + +const THREAD_PERMISSION_CHANGE_KEYS: ReadonlyMap> = new Map([ + [AuditLogActionType.ROLE_CREATE, new Set(['permissions'])], + [AuditLogActionType.ROLE_UPDATE, new Set(['permissions', 'permissions_diff'])], + [AuditLogActionType.ROLE_DELETE, new Set(['permissions'])], + ...[...OVERWRITE_ACTIONS].map((action) => [action, new Set(['allow', 'deny'])] as const), +]); + +const THREAD_SURFACE_CHANGE_KEYS: ReadonlySet = new Set([ + 'thread_metadata', + 'applied_tags', + 'available_tags', + 'default_auto_archive_duration', + 'default_thread_rate_limit_per_user', + 'default_reaction_emoji', + 'default_sort_order', + 'default_forum_layout', + 'default_tag_setting', + 'message_count', + 'total_message_sent', + 'member_ids_preview', + 'member_count', + 'flags', +]); + +const CHANNEL_ACTIONS: ReadonlySet = new Set([ + AuditLogActionType.CHANNEL_CREATE, + AuditLogActionType.CHANNEL_UPDATE, + AuditLogActionType.CHANNEL_DELETE, +]); + +const THREAD_PERMISSION_NAMES: ReadonlySet = new Set(Object.keys(ThreadPermissionFlags)); + +function stripThreadBitsValue(value: unknown): unknown { + if (typeof value === 'string' && /^\d{1,20}$/.test(value)) return (BigInt(value) & ~THREAD_PERMISSIONS).toString(); + if (!isPermissionsDiff(value)) return value; + const added = value.added.filter((name) => !THREAD_PERMISSION_NAMES.has(name)); + const removed = value.removed.filter((name) => !THREAD_PERMISSION_NAMES.has(name)); + if (added.length === value.added.length && removed.length === value.removed.length) return value; + return added.length > 0 || removed.length > 0 ? {added, removed} : undefined; +} + +function isPermissionsDiff(value: unknown): value is {added: Array; removed: Array} { + return ( + typeof value === 'object' && + value !== null && + Array.isArray((value as {added?: unknown}).added) && + Array.isArray((value as {removed?: unknown}).removed) + ); +} + +export function maskThreadAuditLog(log: GuildAuditLog): GuildAuditLog { + if (!log.changes) return log; + const permissionKeys = THREAD_PERMISSION_CHANGE_KEYS.get(log.actionType); + const surfaceKeys = CHANNEL_ACTIONS.has(log.actionType) ? THREAD_SURFACE_CHANGE_KEYS : null; + if (!permissionKeys && !surfaceKeys) return log; + let changed = false; + const masked: GuildAuditLogChange = []; + for (const change of log.changes) { + if (surfaceKeys?.has(change.key)) { + changed = true; + continue; + } + if (!permissionKeys?.has(change.key)) { + masked.push(change); + continue; + } + const next: AuditLogChange = {key: change.key}; + if ('old_value' in change) next.old_value = stripThreadBitsValue(change.old_value); + if ('new_value' in change) next.new_value = stripThreadBitsValue(change.new_value); + changed ||= next.old_value !== change.old_value || next.new_value !== change.new_value; + if (next.old_value === next.new_value) { + changed = true; + continue; + } + masked.push(next); + } + if (!changed) return log; + return new GuildAuditLog({...log.toRow(), changes: masked.length > 0 ? JSON.stringify(masked) : null}); +} + const SNOWFLAKE_PATTERN = /^\d{1,20}$/; export function isNoopGuildAuditLog( @@ -91,7 +213,7 @@ export function isNoopGuildAuditLog( export function mapGuildAuditLogEntry(log: GuildAuditLog): StoredGuildAuditLogEntryResponse { return { id: log.logId.toString(), - action_type: log.actionType, + action_type: log.actionType as GuildAuditLogEntryResponse['action_type'], user_id: log.userId.toString(), target_id: log.targetId, reason: resolveEntryReason(log), diff --git a/fluxer_api/src/api/guild/GuildAuditLogService.ts b/fluxer_api/src/api/guild/GuildAuditLogService.ts index 8237519eb..dcc01bb31 100644 --- a/fluxer_api/src/api/guild/GuildAuditLogService.ts +++ b/fluxer_api/src/api/guild/GuildAuditLogService.ts @@ -2,7 +2,12 @@ import {type ChannelID, createRoleID, type GuildID, type RoleID, type UserID} from '@app/api/BrandedTypes'; import type {GuildAuditLogRow} from '@app/api/database/types/GuildTypes'; -import {isNoopGuildAuditLog, mapGuildAuditLogEntry} from '@app/api/guild/GuildAuditLogEntryMapper'; +import { + isNoopGuildAuditLog, + mapGuildAuditLogEntry, + THREAD_AUDIT_LOG_ACTION_TYPES, + THREAD_SCOPED_AUDIT_OPTION, +} from '@app/api/guild/GuildAuditLogEntryMapper'; import type {AuditLogChange, GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; @@ -38,6 +43,7 @@ interface CreateGuildAuditLogParams { metadata?: Map | Record | Array<[string, string]>; changes?: GuildAuditLogChange | null; createdAt?: Date; + threadScoped?: boolean; } function normalizeAuditLogMetadata(metadata?: CreateGuildAuditLogParams['metadata']): Map { @@ -64,6 +70,9 @@ export class GuildAuditLogService { async createLog(params: CreateGuildAuditLogParams): Promise { const logId = await this.snowflakeService.generate(); const metadataMap = normalizeAuditLogMetadata(params.metadata); + if (params.threadScoped) { + metadataMap.set(THREAD_SCOPED_AUDIT_OPTION, '1'); + } const row: GuildAuditLogRow = { guild_id: params.guildId, log_id: logId, @@ -75,7 +84,7 @@ export class GuildAuditLogService { changes: params.changes ? JSON.stringify(params.changes) : null, }; const log = await this.guildRepository.createAuditLog(row); - await this.dispatchAuditLogEntryCreate(log); + await this.dispatchAuditLogEntryCreate(log, params.threadScoped === true); if (params.actionType === AuditLogActionType.MESSAGE_DELETE) { await this.scheduleMessageDeleteBatchJob(params.guildId); } @@ -181,6 +190,7 @@ export class GuildAuditLogService { // primary key in one batch, and Cassandra resolves that tie in favour of the tombstone, so the entry // would silently vanish from every table whose key does not include action_type. const logId = await this.snowflakeService.generate(); + const threadScoped = group.logs.some((log) => log.options.has(THREAD_SCOPED_AUDIT_OPTION)); const row: GuildAuditLogRow = { guild_id: guildId, log_id: logId, @@ -191,11 +201,12 @@ export class GuildAuditLogService { options: new Map([ ['channel_id', group.channelId], ['count', group.logs.length.toString()], + ...(threadScoped ? [[THREAD_SCOPED_AUDIT_OPTION, '1'] as [string, string]] : []), ]), changes: null, }; const log = await this.guildRepository.batchDeleteAndCreateAuditLogs(guildId, group.logs, row); - await this.dispatchAuditLogEntryCreate(log); + await this.dispatchAuditLogEntryCreate(log, threadScoped); return log; } @@ -370,12 +381,17 @@ export class GuildAuditLogService { return JSON.stringify(a) === JSON.stringify(b); } - private async dispatchAuditLogEntryCreate(log: GuildAuditLog): Promise { + private async dispatchAuditLogEntryCreate(log: GuildAuditLog, threadScoped = false): Promise { + const scoped = threadScoped || THREAD_AUDIT_LOG_ACTION_TYPES.has(log.actionType); try { await this.gatewayService.dispatchGuild({ guildId: log.guildId, event: 'GUILD_AUDIT_LOG_ENTRY_CREATE', - data: {...mapGuildAuditLogEntry(log), guild_id: log.guildId.toString()}, + data: { + ...mapGuildAuditLogEntry(log), + guild_id: log.guildId.toString(), + ...(scoped ? {__thread_scoped: log.guildId.toString()} : {}), + }, }); } catch (error) { Logger.error( @@ -439,6 +455,11 @@ class GuildAuditLogBuilder { return this; } + withThreadScope(scoped = true): this { + this.params.threadScoped = scoped; + return this; + } + withCreatedAt(createdAt?: Date): this { this.params.createdAt = createdAt; return this; @@ -460,6 +481,7 @@ class GuildAuditLogBuilder { metadata: this.metadataMap ?? undefined, changes: this.params.changes ?? null, createdAt: this.params.createdAt, + threadScoped: this.params.threadScoped, }); } } diff --git a/fluxer_api/src/api/guild/controllers/GuildAuditLogController.ts b/fluxer_api/src/api/guild/controllers/GuildAuditLogController.ts index a4fe2153f..45ce89018 100644 --- a/fluxer_api/src/api/guild/controllers/GuildAuditLogController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildAuditLogController.ts @@ -1,14 +1,28 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createGuildID, createUserID} from '@app/api/BrandedTypes'; +import {viewerActive, viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '@app/api/RateLimitConfig'; -import type {HonoApp} from '@app/api/types/HonoEnv'; +import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv'; import {Validator} from '@app/api/Validator'; import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; -import {GuildAuditLogListQuery, GuildAuditLogListResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; +import { + GuildAuditLogListQuery, + GuildAuditLogListQueryWithThreads, + GuildAuditLogListResponse, +} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; +import type {MiddlewareHandler} from 'hono'; + +const controlAuditLogQuery = Validator('query', GuildAuditLogListQuery) as unknown as MiddlewareHandler; + +const ControlAuditLogQueryForNonViewers: MiddlewareHandler = async (ctx, next) => { + const guildId = ctx.req.param('guild_id'); + if (guildId !== undefined && viewerActive(viewerFromCtx(ctx), guildId)) return next(); + return controlAuditLogQuery(ctx, next); +}; export function GuildAuditLogController(app: HonoApp) { app.get( @@ -16,7 +30,8 @@ export function GuildAuditLogController(app: HonoApp) { RateLimitMiddleware(RateLimitConfigs.GUILD_AUDIT_LOGS), LoginRequired, Validator('param', GuildIdParam), - Validator('query', GuildAuditLogListQuery), + ControlAuditLogQueryForNonViewers, + Validator('query', GuildAuditLogListQueryWithThreads), OpenAPI({ operationId: 'list_guild_audit_logs', summary: 'List guild audit logs', @@ -34,6 +49,7 @@ export function GuildAuditLogController(app: HonoApp) { const requestCache = ctx.get('requestCache'); const response = await ctx.get('guildService').listGuildAuditLogs({ userId, + viewer: viewerFromCtx(ctx), guildId, requestCache, limit: query.limit ?? undefined, diff --git a/fluxer_api/src/api/guild/controllers/GuildBaseController.ts b/fluxer_api/src/api/guild/controllers/GuildBaseController.ts index 5c421baeb..e01c54149 100644 --- a/fluxer_api/src/api/guild/controllers/GuildBaseController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildBaseController.ts @@ -4,6 +4,12 @@ import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils'; import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService'; import {createGuildID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; +import { + flagThreadActiveUserGuilds, + maskGuildResponseThreadBits, + maskUserGuildsThreadBits, +} from '@app/api/guild/services/ThreadPermissionBits'; import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils'; import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder'; import {LoginRequired} from '@app/api/middleware/AuthMiddleware'; @@ -100,14 +106,14 @@ export function GuildBaseController(app: HonoApp) { async (ctx) => { const userId = ctx.get('user').id; const {before, after, limit, with_counts} = ctx.req.valid('query'); - return ctx.json( - await ctx.get('guildService').data.getUserGuilds(userId, { - before: before != null ? createGuildID(before) : undefined, - after: after != null ? createGuildID(after) : undefined, - limit, - withCounts: with_counts, - }), - ); + const guilds = await ctx.get('guildService').data.getUserGuilds(userId, { + before: before != null ? createGuildID(before) : undefined, + after: after != null ? createGuildID(after) : undefined, + limit, + withCounts: with_counts, + }); + const viewer = viewerFromCtx(ctx); + return ctx.json(flagThreadActiveUserGuilds(viewer, await maskUserGuildsThreadBits(viewer, guilds))); }, ); app.delete( @@ -197,7 +203,8 @@ export function GuildBaseController(app: HonoApp) { async (ctx) => { const userId = ctx.get('user').id; const guildId = createGuildID(ctx.req.valid('param').guild_id); - return ctx.json(await ctx.get('guildService').data.getGuild({userId, guildId})); + const guild = await ctx.get('guildService').data.getGuild({userId, guildId}); + return ctx.json(await maskGuildResponseThreadBits(guildId, viewerFromCtx(ctx), guild)); }, ); app.patch( @@ -232,7 +239,8 @@ export function GuildBaseController(app: HonoApp) { } const requestCache = ctx.get('requestCache'); const auditLogReason = ctx.get('auditLogReason') ?? null; - return ctx.json(await ctx.get('guildService').updateGuild({userId, guildId, data, requestCache}, auditLogReason)); + const guild = await ctx.get('guildService').updateGuild({userId, guildId, data, requestCache}, auditLogReason); + return ctx.json(await maskGuildResponseThreadBits(guildId, viewerFromCtx(ctx), guild)); }, ); app.post( diff --git a/fluxer_api/src/api/guild/controllers/GuildChannelController.ts b/fluxer_api/src/api/guild/controllers/GuildChannelController.ts index d1075c163..5a4cff964 100644 --- a/fluxer_api/src/api/guild/controllers/GuildChannelController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildChannelController.ts @@ -1,19 +1,32 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createChannelID, createGuildID} from '@app/api/BrandedTypes'; +import {GatedJsonValidator} from '@app/api/channel/threads/GatedJsonValidator'; +import {viewerActive, viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '@app/api/RateLimitConfig'; import type {HonoApp} from '@app/api/types/HonoEnv'; import {Validator} from '@app/api/Validator'; +import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import { + ChannelCreateControlRequest, ChannelCreateRequest, ChannelPositionUpdateRequest, } from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas'; import {ChannelListResponse, ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +const THREAD_PARENT_DEFAULT_KEYS = ['default_auto_archive_duration', 'default_thread_rate_limit_per_user']; + +function touchesThreadChannelCreate(body: unknown): boolean { + if (typeof body !== 'object' || body === null) return false; + const record = body as Record; + if (typeof record.type === 'number' && THREAD_ONLY_CHANNEL_TYPES.has(record.type)) return true; + return THREAD_PARENT_DEFAULT_KEYS.some((key) => record[key] !== undefined); +} + export function GuildChannelController(app: HonoApp) { app.get( '/guilds/:guild_id/channels', @@ -33,7 +46,9 @@ export function GuildChannelController(app: HonoApp) { const userId = ctx.get('user').id; const guildId = createGuildID(ctx.req.valid('param').guild_id); const requestCache = ctx.get('requestCache'); - return ctx.json(await ctx.get('guildService').channels.getChannels({userId, guildId, requestCache})); + return ctx.json( + await ctx.get('guildService').channels.getChannels({userId, guildId, requestCache, viewer: viewerFromCtx(ctx)}), + ); }, ); app.post( @@ -41,10 +56,14 @@ export function GuildChannelController(app: HonoApp) { RateLimitMiddleware(RateLimitConfigs.GUILD_CHANNEL_CREATE), LoginRequired, Validator('param', GuildIdParam), - Validator('json', ChannelCreateRequest), + GatedJsonValidator(ChannelCreateControlRequest, ChannelCreateRequest, { + touchesGate: touchesThreadChannelCreate, + active: (ctx) => viewerActive(viewerFromCtx(ctx), ctx.req.param('guild_id') ?? '0'), + }), OpenAPI({ operationId: 'create_guild_channel', summary: 'Create guild channel', + requestSchema: ChannelCreateRequest, responseSchema: ChannelResponse, statusCode: 200, security: ['botToken', 'bearerToken', 'sessionToken'], @@ -59,7 +78,17 @@ export function GuildChannelController(app: HonoApp) { const requestCache = ctx.get('requestCache'); const auditLogReason = ctx.get('auditLogReason') ?? null; return ctx.json( - await ctx.get('guildService').channels.createChannel({userId, guildId, data, requestCache}, auditLogReason), + await ctx.get('guildService').channels.createChannel( + { + userId, + guildId, + data, + requestCache, + clientFeatures: ctx.get('clientFeatures'), + viewer: viewerFromCtx(ctx), + }, + auditLogReason, + ), ); }, ); @@ -100,6 +129,8 @@ export function GuildChannelController(app: HonoApp) { lockPermissions: item.lock_permissions ?? false, })), requestCache, + clientFeatures: ctx.get('clientFeatures'), + viewer: viewerFromCtx(ctx), }, auditLogReason, ); diff --git a/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts b/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts index 818e284ce..f550756bd 100644 --- a/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts @@ -3,6 +3,7 @@ import {createChannelID, createGuildID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; @@ -122,7 +123,7 @@ export function GuildDiscoveryController(app: HonoApp) { const {guild_id, channel_id} = ctx.req.valid('param'); const preview = await ctx .get('discoveryService') - .getChannelPreview(createGuildID(guild_id), createChannelID(channel_id)); + .getChannelPreview(createGuildID(guild_id), createChannelID(channel_id), viewerFromCtx(ctx)); return ctx.json(preview); }, ); diff --git a/fluxer_api/src/api/guild/controllers/GuildRoleController.ts b/fluxer_api/src/api/guild/controllers/GuildRoleController.ts index 6cde05d81..87ffaa957 100644 --- a/fluxer_api/src/api/guild/controllers/GuildRoleController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildRoleController.ts @@ -1,13 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createGuildID, createRoleID} from '@app/api/BrandedTypes'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {requireOAuth2ScopeForBearer} from '@app/api/middleware/OAuth2ScopeMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '@app/api/RateLimitConfig'; import type {HonoApp} from '@app/api/types/HonoEnv'; -import {CLIENT_FEATURES_HEADER, parseClientFeaturesHeader} from '@app/api/utils/featureUtils'; import {Validator} from '@app/api/Validator'; import {GuildIdParam, GuildIdRoleIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import { @@ -38,7 +38,7 @@ export function GuildRoleController(app: HonoApp) { async (ctx) => { const userId = ctx.get('user').id; const guildId = createGuildID(ctx.req.valid('param').guild_id); - return ctx.json(await ctx.get('guildService').roles.listRoles({userId, guildId})); + return ctx.json(await ctx.get('guildService').roles.listRoles({userId, guildId, viewer: viewerFromCtx(ctx)})); }, ); app.post( @@ -61,10 +61,12 @@ export function GuildRoleController(app: HonoApp) { const userId = ctx.get('user').id; const guildId = createGuildID(ctx.req.valid('param').guild_id); const data = ctx.req.valid('json'); - const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER)); + const clientFeatures = ctx.get('clientFeatures'); const auditLogReason = ctx.get('auditLogReason') ?? null; return ctx.json( - await ctx.get('guildService').roles.createRole({userId, guildId, data, clientFeatures}, auditLogReason), + await ctx + .get('guildService') + .roles.createRole({userId, guildId, data, clientFeatures, viewer: viewerFromCtx(ctx)}, auditLogReason), ); }, ); @@ -145,10 +147,15 @@ export function GuildRoleController(app: HonoApp) { const guildId = createGuildID(guild_id); const roleId = createRoleID(role_id); const data = ctx.req.valid('json'); - const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER)); + const clientFeatures = ctx.get('clientFeatures'); const auditLogReason = ctx.get('auditLogReason') ?? null; return ctx.json( - await ctx.get('guildService').roles.updateRole({userId, guildId, roleId, data, clientFeatures}, auditLogReason), + await ctx + .get('guildService') + .roles.updateRole( + {userId, guildId, roleId, data, clientFeatures, viewer: viewerFromCtx(ctx)}, + auditLogReason, + ), ); }, ); diff --git a/fluxer_api/src/api/guild/repositories/GuildRepository.ts b/fluxer_api/src/api/guild/repositories/GuildRepository.ts index 6cf531224..59be1ca2b 100644 --- a/fluxer_api/src/api/guild/repositories/GuildRepository.ts +++ b/fluxer_api/src/api/guild/repositories/GuildRepository.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {EmojiID, GuildID, RoleID, StickerID, UserID} from '@app/api/BrandedTypes'; +import {enqueueRemoveThreadMemberships} from '@app/api/channel/threads/ThreadJobs'; import type { GuildAuditLogRow, GuildBanRow, @@ -11,6 +12,7 @@ import type { GuildRow, GuildStickerRow, } from '@app/api/database/types/GuildTypes'; +import {everEnabled, isTainted} from '@app/api/experiment/ChannelThreadsGate'; import {GuildContentRepository} from '@app/api/guild/repositories/GuildContentRepository'; import {GuildDataRepository} from '@app/api/guild/repositories/GuildDataRepository'; import {GuildMemberRepository} from '@app/api/guild/repositories/GuildMemberRepository'; @@ -127,7 +129,10 @@ export class GuildRepository implements IGuildRepositoryAggregate { } async deleteMember(guildId: GuildID, userId: UserID): Promise { - return await this.memberRepo.deleteMember(guildId, userId); + await this.memberRepo.deleteMember(guildId, userId); + if (everEnabled() && (await isTainted(guildId, {fresh: true}))) { + await enqueueRemoveThreadMemberships(guildId, userId); + } } async getMembershipMetadata(guildId: GuildID, userId: UserID): Promise { diff --git a/fluxer_api/src/api/guild/services/GuildChannelService.ts b/fluxer_api/src/api/guild/services/GuildChannelService.ts index 6c4eafab9..9eb324223 100644 --- a/fluxer_api/src/api/guild/services/GuildChannelService.ts +++ b/fluxer_api/src/api/guild/services/GuildChannelService.ts @@ -3,10 +3,13 @@ import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; +import {withThreadParentFieldsMany} from '@app/api/channel/services/thread/ThreadParentSettings'; +import {SYSTEM_THREAD_VIEWER, type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import {ChannelOperationsService} from '@app/api/guild/services/channel/ChannelOperationsService'; import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement'; +import {maskChannelResponseThreadBits} from '@app/api/guild/services/ThreadPermissionBits'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; @@ -50,6 +53,7 @@ export class GuildChannelService { userId: UserID; guildId: GuildID; requestCache: RequestCache; + viewer?: ThreadViewer; }): Promise> { try { await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId}); @@ -63,9 +67,14 @@ export class GuildChannelService { guildId: params.guildId, userId: params.userId, }); - const channels = await this.channelRepository.listGuildChannels(params.guildId); - const viewableChannels = channels.filter((channel) => viewableChannelIds.includes(channel.id)); - return Promise.all( + const channels = await this.channelRepository.listGuildChannels(params.guildId, 'enrolled'); + const viewer = params.viewer; + const viewableChannels = channels.filter( + (channel) => + viewableChannelIds.includes(channel.id) && + (!channel.isThreadOnly() || !viewer || viewerActive(viewer, params.guildId)), + ); + const responses = await Promise.all( viewableChannels.map((channel) => { return mapChannelToResponse({ channel, @@ -75,6 +84,17 @@ export class GuildChannelService { }); }), ); + return maskChannelResponseThreadBits( + params.guildId, + viewer, + await withThreadParentFieldsMany( + this.channelRepository.threads, + params.guildId, + viewableChannels, + responses, + viewer ?? SYSTEM_THREAD_VIEWER, + ), + ); } async createChannel( @@ -83,6 +103,8 @@ export class GuildChannelService { guildId: GuildID; data: ChannelCreateRequest; requestCache: RequestCache; + clientFeatures?: ReadonlySet; + viewer?: ThreadViewer; }, auditLogReason?: string | null, ): Promise { @@ -91,7 +113,9 @@ export class GuildChannelService { guildId: params.guildId, permission: Permissions.MANAGE_CHANNELS, }); - return this.channelOps.createChannel(params, auditLogReason); + const response = await this.channelOps.createChannel(params, auditLogReason); + const [masked] = await maskChannelResponseThreadBits(params.guildId, params.viewer, [response]); + return masked; } async updateChannelPositions( @@ -106,6 +130,8 @@ export class GuildChannelService { lockPermissions: boolean; }>; requestCache: RequestCache; + clientFeatures?: ReadonlySet; + viewer?: ThreadViewer; }, auditLogReason?: string | null, ): Promise { @@ -120,6 +146,8 @@ export class GuildChannelService { updates: params.updates, requestCache: params.requestCache, auditLogReason: auditLogReason ?? null, + clientFeatures: params.clientFeatures, + viewer: params.viewer, }); } diff --git a/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts b/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts index 9b04efbda..b900e3a26 100644 --- a/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts +++ b/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts @@ -4,6 +4,7 @@ import {type ChannelID, type GuildID, guildIdToRoleId, type UserID} from '@app/a import {Config} from '@app/api/Config'; import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository'; import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes'; +import {type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate'; import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel'; import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; @@ -22,6 +23,7 @@ import { normalizeDiscoveryTag, } from '@fluxer/constants/src/DiscoveryConstants'; import {GuildFeatures, getEffectiveGuildVerificationLevel} from '@fluxer/constants/src/GuildConstants'; +import {THREAD_CHANNEL_TYPES, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; import {DiscoveryAlreadyAppliedError} from '@fluxer/errors/src/domains/discovery/DiscoveryAlreadyAppliedError'; import {DiscoveryApplicationAlreadyReviewedError} from '@fluxer/errors/src/domains/discovery/DiscoveryApplicationAlreadyReviewedError'; @@ -85,7 +87,11 @@ export abstract class IGuildDiscoveryService { abstract listByStatus(params: {status: string}): Promise>; - abstract getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise; + abstract getChannelPreview( + guildId: GuildID, + channelId: ChannelID, + viewer: ThreadViewer, + ): Promise; abstract searchDiscoverable(params: { query?: string; @@ -393,7 +399,11 @@ export class GuildDiscoveryService extends IGuildDiscoveryService { return this.discoveryRepository.listFullByStatus(params.status); } - async getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise { + async getChannelPreview( + guildId: GuildID, + channelId: ChannelID, + viewer: ThreadViewer, + ): Promise { const [status, guild, channel, everyoneRole] = await Promise.all([ this.discoveryRepository.findByGuildId(guildId), this.guildRepository.findUnique(guildId), @@ -406,7 +416,9 @@ export class GuildDiscoveryService extends IGuildDiscoveryService { guild.features.has(GuildFeatures.INVITES_DISABLED) || !channel || channel.guildId !== guildId || - !everyoneRole + !everyoneRole || + THREAD_CHANNEL_TYPES.has(channel.type) || + (THREAD_ONLY_CHANNEL_TYPES.has(channel.type) && !viewerActive(viewer, guildId)) ) { throw new DiscoveryNotDiscoverableError(); } diff --git a/fluxer_api/src/api/guild/services/GuildMfaEnforcement.ts b/fluxer_api/src/api/guild/services/GuildMfaEnforcement.ts index 4b4665b15..caeb3d307 100644 --- a/fluxer_api/src/api/guild/services/GuildMfaEnforcement.ts +++ b/fluxer_api/src/api/guild/services/GuildMfaEnforcement.ts @@ -4,6 +4,7 @@ import type {UserID} from '@app/api/BrandedTypes'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants'; +import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; @@ -16,7 +17,8 @@ const ELEVATED_MFA_PERMISSIONS = Permissions.MANAGE_MESSAGES | Permissions.MANAGE_ROLES | Permissions.MANAGE_WEBHOOKS | - Permissions.MODERATE_MEMBERS; + Permissions.MODERATE_MEMBERS | + ThreadPermissionFlags.MANAGE_THREADS; export async function createGuildMfaEnforcer(params: { userRepository: IUserRepository; diff --git a/fluxer_api/src/api/guild/services/GuildRoleService.ts b/fluxer_api/src/api/guild/services/GuildRoleService.ts index d6d894f8a..f573451aa 100644 --- a/fluxer_api/src/api/guild/services/GuildRoleService.ts +++ b/fluxer_api/src/api/guild/services/GuildRoleService.ts @@ -2,6 +2,7 @@ import type {GuildID, RoleID, UserID} from '@app/api/BrandedTypes'; import {createRoleID, guildIdToRoleId} from '@app/api/BrandedTypes'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes'; import {mapGuildRoleToResponse} from '@app/api/guild/GuildModel'; @@ -9,6 +10,12 @@ import type {IGuildMemberRepository} from '@app/api/guild/repositories/IGuildMem import type {IGuildRoleRepository} from '@app/api/guild/repositories/IGuildRoleRepository'; import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement'; import {computeMovedIds, getMemberListRoleOrderIds} from '@app/api/guild/services/role/RoleOrderAuditUtils'; +import { + resolveProtectedBitActor, + resolveThreadPermissionMode, + shouldMaskThreadPermissionBits, + stripThreadPermissionBits, +} from '@app/api/guild/services/ThreadPermissionBits'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; import {Logger} from '@app/api/Logger'; @@ -17,12 +24,13 @@ import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils'; import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder'; import {GuildRole} from '@app/api/models/GuildRole'; import type {IUserRepository} from '@app/api/user/IUserRepository'; -import {applyProtectedRolePermissions} from '@app/api/utils/featureUtils'; +import {applyProtectedRolePermissions, permissionWriteMask, protectedThreadBits} from '@app/api/utils/featureUtils'; import {computePermissionsDiff} from '@app/api/utils/PermissionUtils'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {ALL_PERMISSIONS, DEFAULT_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants'; import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; import {MAX_GUILD_ROLES} from '@fluxer/constants/src/LimitConstants'; +import {THREAD_AWARE_ALL_PERMISSIONS, withImplicitThreadBits} from '@fluxer/constants/src/ThreadPermissionUtils'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError'; @@ -78,7 +86,9 @@ export class GuildRoleService { const currentRoleCount = await this.guildRepository.countRoles(guildId); const roleLimit = this.resolveGuildLimit(guildData.features, 'max_guild_roles', MAX_GUILD_ROLES); if (currentRoleCount >= roleLimit) throw new MaxGuildRolesError(roleLimit); - const permissions = data.permissions !== undefined ? data.permissions & ALL_PERMISSIONS : DEFAULT_PERMISSIONS; + const permissionMask = + (await resolveThreadPermissionMode(guildId)) === 'active' ? THREAD_AWARE_ALL_PERMISSIONS : ALL_PERMISSIONS; + const permissions = data.permissions !== undefined ? data.permissions & permissionMask : DEFAULT_PERMISSIONS; const roleId = createRoleID(await this.snowflakeService.generate()); const position = 1; const role = await this.guildRepository.upsertRole({ @@ -113,10 +123,11 @@ export class GuildRoleService { guildId: GuildID; data: GuildRoleCreateRequest; clientFeatures: ReadonlySet; + viewer?: ThreadViewer; }, auditLogReason?: string | null, ): Promise { - const {userId, guildId, data, clientFeatures} = params; + const {userId, guildId, data, clientFeatures, viewer} = params; const {checkPermission, getMyPermissions, guildData} = await this.getGuildAuthenticated({userId, guildId}); await checkPermission(Permissions.MANAGE_ROLES); const currentRoleCount = await this.guildRepository.countRoles(guildId); @@ -128,8 +139,10 @@ export class GuildRoleService { requestedPermissions: data.permissions, existingPermissions: 0n, clientFeatures, + guildId, guildData, userId, + viewer, getMyPermissions, }) : ((await this.guildRepository.getRole(guildIdToRoleId(guildId), guildId))?.permissions ?? DEFAULT_PERMISSIONS); @@ -158,35 +171,57 @@ export class GuildRoleService { auditLogReason: auditLogReason ?? null, changes: this.guildAuditLogService.computeChanges(null, this.serializeRoleForAudit(role)), }); - return mapGuildRoleToResponse(role); + return this.mapRoleForViewer(guildId, role, viewer); } private async resolveRequestedPermissions(params: { requestedPermissions: bigint; existingPermissions: bigint; clientFeatures: ReadonlySet; + guildId: GuildID; guildData: { owner_id: string; }; userId: UserID; + viewer?: ThreadViewer; getMyPermissions: () => Promise; }): Promise { const {requestedPermissions, existingPermissions, clientFeatures, guildData, userId, getMyPermissions} = params; - const sanitizedPermissions = applyProtectedRolePermissions( - requestedPermissions & ALL_PERMISSIONS, - existingPermissions, + const actor = await resolveProtectedBitActor({ + guildId: params.guildId, + userId, clientFeatures, + viewer: params.viewer, + isBot: async () => + params.viewer?.kind === 'user' + ? params.viewer.bot + : ((await this.userRepository.findUnique(userId))?.isBot ?? false), + }); + const sanitizedPermissions = applyProtectedRolePermissions( + requestedPermissions & permissionWriteMask(actor), + existingPermissions, + actor, ); const isOwner = guildData && guildData.owner_id === userId.toString(); if (!isOwner) { - const myPermissions = await getMyPermissions(); - if ((sanitizedPermissions & ~myPermissions) !== 0n) { + const myPermissions = withImplicitThreadBits(await getMyPermissions()); + if ((sanitizedPermissions & ~protectedThreadBits(actor) & ~myPermissions) !== 0n) { throw new MissingPermissionsError(); } } return sanitizedPermissions; } + private async mapRoleForViewer( + guildId: GuildID, + role: GuildRole, + viewer: ThreadViewer | undefined, + ): Promise { + const response = mapGuildRoleToResponse(role); + if (!(await shouldMaskThreadPermissionBits(guildId, viewer, [role.permissions]))) return response; + return {...response, permissions: stripThreadPermissionBits(role.permissions).toString()}; + } + async updateRole( params: { userId: UserID; @@ -194,10 +229,11 @@ export class GuildRoleService { roleId: RoleID; data: GuildRoleUpdateRequest; clientFeatures: ReadonlySet; + viewer?: ThreadViewer; }, auditLogReason?: string | null, ): Promise { - const {userId, guildId, roleId, data, clientFeatures} = params; + const {userId, guildId, roleId, data, clientFeatures, viewer} = params; const {guildData, checkPermission, getMyPermissions} = await this.getGuildAuthenticated({userId, guildId}); await checkPermission(Permissions.MANAGE_ROLES); const role = await this.guildRepository.getRole(roleId, guildId); @@ -219,6 +255,7 @@ export class GuildRoleService { userId, data, clientFeatures, + viewer, getMyPermissions, }); const updatedRoleData = { @@ -237,7 +274,9 @@ export class GuildRoleService { await this.dispatchGuildRoleUpdate({guildId, role: updatedRole}); const changes = this.guildAuditLogService.computeChanges(previousSnapshot, this.serializeRoleForAudit(updatedRole)); if (role.permissions !== updatedRole.permissions) { - const permissionsDiff = computePermissionsDiff(role.permissions, updatedRole.permissions); + const permissionsDiff = computePermissionsDiff(role.permissions, updatedRole.permissions, { + threads: (await resolveThreadPermissionMode(guildId)) !== 'control', + }); changes.push({key: 'permissions_diff', new_value: permissionsDiff}); } await this.recordAuditLog({ @@ -249,7 +288,7 @@ export class GuildRoleService { metadata: {role_name: updatedRole.name}, changes, }); - return mapGuildRoleToResponse(updatedRole); + return this.mapRoleForViewer(guildId, updatedRole, viewer); } async deleteRole( @@ -327,15 +366,32 @@ export class GuildRoleService { } } - async listRoles(params: {userId: UserID; guildId: GuildID}): Promise> { - const {userId, guildId} = params; + async listRoles(params: { + userId: UserID; + guildId: GuildID; + viewer?: ThreadViewer; + }): Promise> { + const {userId, guildId, viewer} = params; await this.getGuildAuthenticated({userId, guildId}); const roles = await this.guildRepository.listRoles(guildId); const sortedRoles = [...roles].sort((a, b) => { if (b.position !== a.position) return b.position - a.position; return this.compareRoleIds(a, b); }); - return sortedRoles.map(mapGuildRoleToResponse); + const responses = sortedRoles.map(mapGuildRoleToResponse); + if ( + !(await shouldMaskThreadPermissionBits( + guildId, + viewer, + sortedRoles.map((role) => role.permissions), + )) + ) { + return responses; + } + return responses.map((response, index) => ({ + ...response, + permissions: stripThreadPermissionBits(sortedRoles[index]!.permissions).toString(), + })); } async updateHoistPositions( @@ -544,9 +600,10 @@ export class GuildRoleService { userId: UserID; data: GuildRoleUpdateRequest; clientFeatures: ReadonlySet; + viewer?: ThreadViewer; getMyPermissions: () => Promise; }): Promise { - const {role, guildId, guildData, userId, data, clientFeatures, getMyPermissions} = params; + const {role, guildId, guildData, userId, data, clientFeatures, viewer, getMyPermissions} = params; const updateData: RoleUpdateData = {}; const isEveryoneRole = role.id === guildIdToRoleId(guildId); if (data.name !== undefined && !isEveryoneRole) { @@ -569,8 +626,10 @@ export class GuildRoleService { requestedPermissions: data.permissions, existingPermissions: role.permissions, clientFeatures, + guildId, guildData, userId, + viewer, getMyPermissions, }); } diff --git a/fluxer_api/src/api/guild/services/GuildSearchService.ts b/fluxer_api/src/api/guild/services/GuildSearchService.ts index 80e42d460..9b037e740 100644 --- a/fluxer_api/src/api/guild/services/GuildSearchService.ts +++ b/fluxer_api/src/api/guild/services/GuildSearchService.ts @@ -2,6 +2,14 @@ import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; +import {threadSearchIndexEnqueueCappedTotal} from '@app/api/channel/threads/ThreadMetrics'; +import { + guildActive, + THREAD_FEATURE_CHANNEL_TYPES, + THREAD_PARENT_CHANNEL_TYPES, + type ThreadViewer, + viewerActive, +} from '@app/api/experiment/ChannelThreadsGate'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; @@ -12,12 +20,14 @@ import {channelNeedsReindexing} from '@app/api/search/ChannelIndexingUtils'; import {MessageSearchResponseMapper} from '@app/api/search/MessageSearchResponseMapper'; import {searchExistingMessages} from '@app/api/search/MessageSearchResultReconciler'; import {channelRequiresAgeVerification} from '@app/api/search/SearchNsfwUtils'; +import {accessibleRequestedThreadIds, accessibleThreadIds} from '@app/api/search/ThreadSearchScope'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils'; import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants'; +import {SEARCH_INDEX_ENQUEUE_MAX} from '@fluxer/constants/src/ThreadConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; @@ -47,12 +57,13 @@ export class GuildSearchService { async searchMessages(params: { userId: UserID; + viewer: ThreadViewer; guildId: GuildID; channelIds: Array; searchParams: MessageSearchRequest; requestCache: RequestCache; }): Promise { - const {userId, guildId, searchParams, requestCache} = params; + const {userId, viewer, guildId, searchParams, requestCache} = params; let {channelIds} = params; const guildData = await this.gatewayService.getGuildData({guildId, userId}); const guildIsAgeRestricted = guildData?.nsfw_level === GuildNSFWLevel.AGE_RESTRICTED; @@ -60,10 +71,11 @@ export class GuildSearchService { if (!searchService) { throw new FeatureTemporarilyDisabledError(); } + const threadsVisible = viewerActive(viewer, guildId); const explicitChannelIds = channelIds.length > 0; if (!explicitChannelIds) { - const channels = await this.channelRepository.listGuildChannels(guildId); - channelIds = channels.map((c) => c.id); + const channels = await this.channelRepository.listGuildChannels(guildId, 'enrolled'); + channelIds = channels.filter((c) => threadsVisible || !c.isThreadOnly()).map((c) => c.id); } const includeNsfwRequested = searchParams.include_nsfw ?? false; const canUserAccessNsfw = @@ -76,7 +88,7 @@ export class GuildSearchService { const channels = await this.channelRepository.listChannels(channelIds); const channelMap = new Map(); for (const channel of channels) { - if (channel.guildId === guildId) { + if (channel.guildId === guildId && (threadsVisible || !THREAD_FEATURE_CHANNEL_TYPES.has(channel.type))) { channelMap.set(channel.id.toString(), channel); } } @@ -85,6 +97,25 @@ export class GuildSearchService { throw InputValidationError.fromCode('channel_ids', ValidationErrorCodes.ALL_CHANNELS_MUST_BELONG_TO_GUILD); } } + const requestedThreads = explicitChannelIds + ? channels.filter((channel) => channelMap.get(channel.id.toString())?.isThread()) + : []; + if (requestedThreads.length > 0) { + const threadIds = new Set(requestedThreads.map((thread) => thread.id.toString())); + const parentIds = requestedThreads.flatMap((thread) => (thread.parentId ? [thread.parentId] : [])); + for (const parent of await this.channelRepository.listChannels( + parentIds.filter((id) => !channelMap.has(id.toString())), + )) { + if (parent.guildId === guildId) channelMap.set(parent.id.toString(), parent); + } + channelIds = [ + ...new Map( + [...channelIds.filter((id) => !threadIds.has(id.toString())), ...parentIds] + .filter((id) => channelMap.has(id.toString())) + .map((id) => [id.toString(), id] as const), + ).values(), + ]; + } const categoryLookup = await this.buildParentCategoryLookup(channelMap); const nsfwFilteredIds = channelIds.filter((id) => { const channel = channelMap.get(id.toString())!; @@ -119,12 +150,16 @@ export class GuildSearchService { page, }; } - const channelsToIndex = validChannelIds + let channelsToIndex = validChannelIds .filter((channelId) => { const channel = channelMap.get(channelId.toString()); - return channel && channelNeedsReindexing(channel.indexedAt); + return channel && !channel.isThread() && !channel.isThreadOnly() && channelNeedsReindexing(channel.indexedAt); }) .map((id) => id.toString()); + if (channelsToIndex.length > SEARCH_INDEX_ENQUEUE_MAX && guildActive(guildId)) { + channelsToIndex = channelsToIndex.slice(0, SEARCH_INDEX_ENQUEUE_MAX); + threadSearchIndexEnqueueCappedTotal.inc(); + } if (channelsToIndex.length > 0) { await mapWithConcurrency(channelsToIndex, CHANNEL_INDEX_JOB_ENQUEUE_CONCURRENCY, (channelId) => this.workerService.addJob( @@ -140,10 +175,16 @@ export class GuildSearchService { ); return {indexing: true}; } - const filters = buildMessageSearchFilters( - searchParams, - validChannelIds.map((id) => id.toString()), - ); + const searchChannelIds = threadsVisible + ? await this.threadSearchChannelIds({ + userId, + guildId, + validChannelIds, + channelMap, + requestedIds: explicitChannelIds ? new Set(params.channelIds.map((id) => id.toString())) : null, + }) + : validChannelIds.map((id) => id.toString()); + const filters = buildMessageSearchFilters(searchParams, searchChannelIds); const hitsPerPage = searchParams.hits_per_page ?? 25; const page = searchParams.page ?? 1; const cursor = searchParams.cursor; @@ -156,7 +197,12 @@ export class GuildSearchService { page, cursor, }); - const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache); + const mappedResponses = await this.responseMapper.mapSearchResultToResponses( + result.messages, + userId, + viewer, + requestCache, + ); return { messages: mappedResponses.messages, channels: mappedResponses.channels, @@ -164,31 +210,33 @@ export class GuildSearchService { hits_per_page: hitsPerPage, page, cursor: result.cursor, + ...(mappedResponses.threads ? {threads: mappedResponses.threads, members: mappedResponses.members} : {}), }; } async searchAllGuilds(params: { userId: UserID; + viewer: ThreadViewer; channelIds: Array; searchParams: MessageSearchRequest; requestCache: RequestCache; }): Promise { - const {userId, channelIds, searchParams, requestCache} = params; + const {userId, viewer, channelIds, searchParams, requestCache} = params; const searchService = getMessageSearchService(); if (!searchService) { throw new FeatureTemporarilyDisabledError(); } - const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} = - await this.collectAccessibleGuildChannels(userId); + const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories, threadParents} = + await this.collectAccessibleGuildChannels(userId, viewer, channelIds); if (unindexedChannelIds.size > 0) { await this.queueIndexingChannels(unindexedChannelIds); return {indexing: true}; } - let searchChannelIds = Array.from(accessibleChannels.keys()); + let searchChannelIds = [...accessibleChannels.keys(), ...threadParents.keys()]; if (channelIds.length > 0) { const requestedChannelStrings = channelIds.map((id) => id.toString()); for (const requested of requestedChannelStrings) { - if (!accessibleChannels.has(requested)) { + if (!accessibleChannels.has(requested) && !threadParents.has(requested)) { throw new MissingPermissionsError(); } } @@ -200,7 +248,7 @@ export class GuildSearchService { canIncludeNsfw = await this.getCanUserAccessNsfw(userId); } searchChannelIds = searchChannelIds.filter((channelIdStr) => { - const channel = accessibleChannels.get(channelIdStr); + const channel = accessibleChannels.get(channelIdStr) ?? threadParents.get(channelIdStr); if (!channel) { return false; } @@ -238,7 +286,12 @@ export class GuildSearchService { page, cursor, }); - const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache); + const mappedResponses = await this.responseMapper.mapSearchResultToResponses( + result.messages, + userId, + viewer, + requestCache, + ); return { messages: mappedResponses.messages, channels: mappedResponses.channels, @@ -246,9 +299,54 @@ export class GuildSearchService { hits_per_page: hitsPerPage, page, cursor: result.cursor, + ...(mappedResponses.threads ? {threads: mappedResponses.threads, members: mappedResponses.members} : {}), }; } + private async threadSearchChannelIds(params: { + userId: UserID; + guildId: GuildID; + validChannelIds: Array; + channelMap: Map; + requestedIds: Set | null; + }): Promise> { + const {channelMap, requestedIds} = params; + const parents = params.validChannelIds.filter((id) => { + const channel = channelMap.get(id.toString()); + return channel !== undefined && THREAD_PARENT_CHANNEL_TYPES.has(channel.type); + }); + const ids = params.validChannelIds + .map((id) => id.toString()) + .filter((id) => !channelMap.get(id)?.isThreadOnly() && (requestedIds === null || requestedIds.has(id))); + const scopeParents = + requestedIds === null + ? parents + : parents.filter((id) => requestedIds.has(id.toString()) && channelMap.get(id.toString())?.isThreadOnly()); + const scope = await accessibleThreadIds({ + gatewayService: this.gatewayService, + userId: params.userId, + groups: [{guildId: params.guildId, parentIds: scopeParents}], + }); + if (requestedIds === null) return [...ids, ...scope.keys()]; + const visibleParents = new Set(parents.map((id) => id.toString())); + const requestedThreads = [...requestedIds].flatMap((id) => { + const channel = channelMap.get(id); + if (!channel?.isThread()) return []; + const parentVisible = channel.parentId !== null && visibleParents.has(channel.parentId.toString()); + return [{id: channel.id, type: channel.type, parentId: parentVisible ? channel.parentId : null}]; + }); + const accessible = await accessibleRequestedThreadIds({ + gatewayService: this.gatewayService, + userId: params.userId, + guildId: params.guildId, + threads: requestedThreads, + }); + for (const thread of requestedThreads) { + if (!accessible.has(thread.id.toString())) throw new MissingPermissionsError(); + } + return [...new Set([...ids, ...scope.keys(), ...accessible])]; + } + private async buildParentCategoryLookup(channelMap: Map): Promise> { const lookup = new Map(channelMap); const missingParentIds: Array = []; @@ -288,11 +386,45 @@ export class GuildSearchService { ); } - async collectAccessibleGuildChannels(userId: UserID): Promise<{ + private async addRequestedThreads( + userId: UserID, + threadIds: ReadonlyArray, + parentById: Map, + threadParents: Map, + ): Promise { + const threadsByGuild = new Map>(); + for (const thread of await this.channelRepository.listChannels([...threadIds])) { + const parent = thread.parentId === null ? undefined : parentById.get(thread.parentId.toString()); + if (!thread.isThread() || !parent || thread.guildId === null || parent.guildId !== thread.guildId) continue; + const threads = threadsByGuild.get(thread.guildId.toString()); + if (threads) threads.push(thread); + else threadsByGuild.set(thread.guildId.toString(), [thread]); + } + await mapWithConcurrency([...threadsByGuild.values()], GUILD_FANOUT_CONCURRENCY, async (threads) => { + const accessible = await accessibleRequestedThreadIds({ + gatewayService: this.gatewayService, + userId, + guildId: threads[0]!.guildId!, + threads: threads.map((thread) => ({id: thread.id, parentId: thread.parentId, type: thread.type})), + }); + for (const thread of threads) { + if (accessible.has(thread.id.toString())) { + threadParents.set(thread.id.toString(), parentById.get(thread.parentId!.toString())!); + } + } + }); + } + + async collectAccessibleGuildChannels( + userId: UserID, + viewer?: ThreadViewer, + requestedChannelIds?: ReadonlyArray, + ): Promise<{ accessibleChannels: Map; unindexedChannelIds: Set; guildNsfwLevels: Map; parentCategories: Map; + threadParents: Map; }> { const guildIds = await this.userRepository.getUserGuildIds(userId); const accessibleChannels = new Map(); @@ -306,7 +438,13 @@ export class GuildSearchService { await mapWithConcurrency(guildIds, GUILD_FANOUT_CONCURRENCY, async (guildId) => { const [guildData, guildChannels, viewableChannels] = await Promise.all([ this.gatewayService.getGuildData({guildId, userId}), - this.channelRepository.listGuildChannels(guildId), + this.channelRepository + .listGuildChannels(guildId, 'enrolled') + .then((channels) => + channels.filter( + (channel) => !channel.isThreadOnly() || (viewer !== undefined && viewerActive(viewer, guildId)), + ), + ), this.gatewayService.getViewableChannels({guildId, userId}), ]); if (guildData) { @@ -333,17 +471,45 @@ export class GuildSearchService { permission: Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY, }), ); + const threadParentsByGuild = new Map>(); + const parentById = new Map(); for (let i = 0; i < permissionChecks.length; i++) { if (!permissionResults[i]) { continue; } - const {channel} = permissionChecks[i]!; + const {channel, guildId} = permissionChecks[i]!; + if (viewer !== undefined && THREAD_PARENT_CHANNEL_TYPES.has(channel.type) && viewerActive(viewer, guildId)) { + const parentIds = threadParentsByGuild.get(guildId); + if (parentIds) parentIds.push(channel.id); + else threadParentsByGuild.set(guildId, [channel.id]); + parentById.set(channel.id.toString(), channel); + } + if (channel.isThreadOnly()) { + continue; + } const channelIdStr = channel.id.toString(); accessibleChannels.set(channelIdStr, channel); if (channelNeedsReindexing(channel.indexedAt)) { unindexedChannelIds.add(channelIdStr); } } - return {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories}; + const scope = await accessibleThreadIds({ + gatewayService: this.gatewayService, + userId, + groups: Array.from(threadParentsByGuild, ([guildId, parentIds]) => ({guildId, parentIds})), + }); + const threadParents = new Map( + Array.from(scope, ([threadId, parentId]) => [threadId, parentById.get(parentId.toString())!] as const), + ); + const missingThreadIds = + parentById.size > 0 + ? (requestedChannelIds ?? []).filter( + (id) => !accessibleChannels.has(id.toString()) && !threadParents.has(id.toString()), + ) + : []; + if (missingThreadIds.length > 0) { + await this.addRequestedThreads(userId, missingThreadIds, parentById, threadParents); + } + return {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories, threadParents}; } } diff --git a/fluxer_api/src/api/guild/services/GuildService.ts b/fluxer_api/src/api/guild/services/GuildService.ts index bc078a3f2..6ba672442 100644 --- a/fluxer_api/src/api/guild/services/GuildService.ts +++ b/fluxer_api/src/api/guild/services/GuildService.ts @@ -2,15 +2,21 @@ import type {ApiContext} from '@app/api/ApiContext'; import type {EmojiID, GuildID, RoleID, StickerID, UserID} from '@app/api/BrandedTypes'; -import {createWebhookID} from '@app/api/BrandedTypes'; +import {createChannelID, createWebhookID} from '@app/api/BrandedTypes'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; +import {mapThreadToResponse} from '@app/api/channel/services/thread/ThreadMappers'; +import {everEnabled, isTainted, type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate'; import {resolveExpressionSourceGuild} from '@app/api/guild/ExpressionSourceGuild'; import { + collectAuditLogChannelIds, collectGuildAuditLogUserIds, isNoopGuildAuditLog, + isThreadScopedAuditLog, mapGuildAuditLogEntry, + maskThreadAuditLog, type StoredGuildAuditLogEntryResponse, + THREAD_AUDIT_LOG_ACTION_TYPES, } from '@app/api/guild/GuildAuditLogEntryMapper'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; @@ -38,6 +44,7 @@ import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; +import {THREAD_FEATURE_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError'; @@ -46,6 +53,7 @@ import {ResourceLockedError} from '@fluxer/errors/src/domains/core/ResourceLocke import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; import type { AuditLogWebhookResponse, GuildAuditLogListResponse, @@ -102,7 +110,7 @@ export class GuildService { constructor( apiContext: ApiContext, guildRepository: IGuildRepositoryAggregate, - channelRepository: IChannelRepository, + private readonly channelRepository: IChannelRepository, inviteRepository: InviteRepository, channelService: ChannelService, userCacheService: UserCacheService, @@ -294,6 +302,7 @@ export class GuildService { async listGuildAuditLogs(params: { userId: UserID; + viewer: ThreadViewer; guildId: GuildID; requestCache: RequestCache; limit?: number; @@ -328,8 +337,11 @@ export class GuildService { afterLogId?: bigint; filterUserId?: UserID; actionType?: AuditLogActionType; + viewer?: ThreadViewer; }): Promise { const {guildId, requestCache, limit = 50, beforeLogId, afterLogId, filterUserId, actionType} = params; + const threadsVisible = params.viewer === undefined || viewerActive(params.viewer, guildId); + const hideThreadEntries = !threadsVisible && everEnabled() && (await isTainted(guildId)); if (beforeLogId !== undefined && afterLogId !== undefined) { throw InputValidationError.fromCode('before', ValidationErrorCodes.CANNOT_SPECIFY_BOTH_BEFORE_AND_AFTER); } @@ -351,18 +363,15 @@ export class GuildService { if (logs.length === 0) { break; } - if (shouldBatch) { - const batchResult = await this.guildAuditLogService.batchConsecutiveMessageDeleteLogs(guildId, logs); - for (const log of batchResult.processedLogs) { - if (processedLogs.length < effectiveLimit && !isNoopGuildAuditLog(log.actionType, log.changes)) { - processedLogs.push(log); - } - } - } else { - for (const log of logs) { - if (processedLogs.length < effectiveLimit && !isNoopGuildAuditLog(log.actionType, log.changes)) { - processedLogs.push(log); - } + const batchedLogs = shouldBatch + ? (await this.guildAuditLogService.batchConsecutiveMessageDeleteLogs(guildId, logs)).processedLogs + : logs; + const visibleLogs = hideThreadEntries + ? (await this.dropThreadScopedAuditLogs(batchedLogs)).map(maskThreadAuditLog) + : batchedLogs; + for (const log of visibleLogs) { + if (processedLogs.length < effectiveLimit && !isNoopGuildAuditLog(log.actionType, log.changes)) { + processedLogs.push(log); } } if (logs.length < fetchLimit) { @@ -392,12 +401,79 @@ export class GuildService { ]); const entries = processedLogs.map((log) => mapGuildAuditLogEntry(log)); const users = Array.from(userPartials.values()); - const webhooks = this.buildAuditLogWebhookResponses(webhookRecords.webhooks); - return { + const webhooks = this.buildAuditLogWebhookResponses( + hideThreadEntries ? await this.dropForumWebhooks(webhookRecords.webhooks) : webhookRecords.webhooks, + ); + const response: StoredGuildAuditLogListResponse = { audit_log_entries: entries, users, webhooks, }; + if (params.viewer !== undefined && threadsVisible) { + response.threads = await this.loadAuditLogThreads(processedLogs); + } + return response; + } + + private async dropThreadScopedAuditLogs(logs: Array): Promise> { + const candidateIds = [...new Set(logs.flatMap((log) => collectAuditLogChannelIds(log)))].filter((id) => + /^\d{1,20}$/.test(id), + ); + const channels = + candidateIds.length > 0 + ? await this.channelRepository.listChannels(candidateIds.map((id) => createChannelID(BigInt(id)))) + : []; + const gatedIds = new Set( + channels + .filter((channel) => THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) + .map((channel) => channel.id.toString()), + ); + return logs.filter((log) => !isThreadScopedAuditLog(log, gatedIds)); + } + + private async dropForumWebhooks(webhooks: Array): Promise> { + const channelIds = [...new Set(webhooks.flatMap((webhook) => (webhook.channelId ? [webhook.channelId] : [])))]; + if (channelIds.length === 0) return webhooks; + const forumIds = new Set( + (await this.channelRepository.listChannels(channelIds)) + .filter((channel) => channel.isThreadOnly()) + .map((channel) => channel.id), + ); + return webhooks.filter((webhook) => !webhook.channelId || !forumIds.has(webhook.channelId)); + } + + private async loadAuditLogThreads(logs: Array): Promise> { + const threadIds = [ + ...new Set( + logs + .filter((log) => THREAD_AUDIT_LOG_ACTION_TYPES.has(log.actionType) && log.targetId) + .map((log) => log.targetId!), + ), + ] + .filter((id) => /^\d{1,20}$/.test(id)) + .map((id) => createChannelID(BigInt(id))); + if (threadIds.length === 0) return []; + const [channels, states, stats] = await Promise.all([ + this.channelRepository.listChannels(threadIds), + this.channelRepository.threads.getStates(threadIds), + this.channelRepository.threads.getStatsMany(threadIds), + ]); + const stateById = new Map(states.map((state) => [state.threadId.toString(), state])); + const parents = await this.channelRepository.listChannels([...new Set(states.map((state) => state.parentId))]); + const parentTypeById = new Map(parents.map((parent) => [parent.id.toString(), parent.type])); + return channels.flatMap((channel) => { + const state = stateById.get(channel.id.toString()); + const threadStats = stats.get(channel.id); + if (!state || !threadStats || !channel.isThread()) return []; + return [ + mapThreadToResponse({ + channel, + state, + stats: threadStats, + parentType: parentTypeById.get(state.parentId.toString()) ?? null, + }), + ]; + }); } private async loadAuditLogWebhooks(logs: Array): Promise<{ diff --git a/fluxer_api/src/api/guild/services/ThreadPermissionBits.test.ts b/fluxer_api/src/api/guild/services/ThreadPermissionBits.test.ts new file mode 100644 index 000000000..9e0b555dd --- /dev/null +++ b/fluxer_api/src/api/guild/services/ThreadPermissionBits.test.ts @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createGuildID, createUserID, type GuildID} from '@app/api/BrandedTypes'; +import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository'; +import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes'; +import { + clearChannelThreadsTaintCacheForTesting, + isTainted, + syncChannelThreadsConfig, +} from '@app/api/experiment/ChannelThreadsGate'; +import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement'; +import { + maskGuildResponseThreadBits, + maskUserGuildsThreadBits, + resolveProtectedBitActor, + resolveThreadPermissionMode, + shouldMaskThreadPermissionBits, +} from '@app/api/guild/services/ThreadPermissionBits'; +import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import { + applyProtectedOverwriteBits, + applyProtectedRolePermissions, + type ProtectedBitActor, + permissionWriteMask, + protectedThreadBits, +} from '@app/api/utils/featureUtils'; +import {computePermissionsDiff} from '@app/api/utils/PermissionUtils'; +import { + enabledThreadGuildIds, + enqueueThreadPermissionSeeds, + newlyEnabledThreadGuildIds, + seedThreadOverwriteBits, + seedThreadOverwriteValue, +} from '@app/api/worker/tasks/SeedThreadPermissions'; +import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; +import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants'; +import { + DEFAULT_THREAD_PERMISSIONS, + THREAD_AWARE_ALL_PERMISSIONS, + THREAD_PERMISSIONS, + ThreadPermissionFlags, +} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError'; +import { + ChannelThreadsConfigSchema, + DEFAULT_CHANNEL_THREADS_CONFIG, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; +import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +const NONE: ReadonlySet = new Set(); +const CAPABLE: ReadonlySet = new Set(['channel_threads']); +const MANAGE_THREADS = ThreadPermissionFlags.MANAGE_THREADS; +const VCM = Permissions.VIEW_CHANNEL_MEMBERS; + +function actor( + mode: 'control' | 'active' | 'retired', + opts: {bot?: boolean; userActive?: boolean; features?: ReadonlySet} = {}, +): ProtectedBitActor { + return mode === 'control' + ? {clientFeatures: opts.features ?? NONE} + : { + clientFeatures: opts.features ?? NONE, + threadBits: { + mode, + writer: (opts.bot ?? false) || ((opts.userActive ?? false) && (opts.features ?? NONE).has('channel_threads')), + }, + }; +} + +describe('thread bits in the protected permission helper', () => { + it('stays inert in control guilds', () => { + const control = actor('control'); + expect(permissionWriteMask(control)).toBe(ALL_PERMISSIONS); + expect(protectedThreadBits(control)).toBe(0n); + expect(applyProtectedRolePermissions(Permissions.SEND_MESSAGES, MANAGE_THREADS, control)).toBe( + Permissions.SEND_MESSAGES, + ); + }); + + it('lets bots and capable active users write, and restores for everyone else', () => { + const writers = [actor('active', {bot: true}), actor('active', {userActive: true, features: CAPABLE})]; + for (const writer of writers) { + expect(permissionWriteMask(writer)).toBe(THREAD_AWARE_ALL_PERMISSIONS); + expect(protectedThreadBits(writer)).toBe(0n); + expect(applyProtectedRolePermissions(DEFAULT_THREAD_PERMISSIONS, MANAGE_THREADS, writer)).toBe( + DEFAULT_THREAD_PERMISSIONS, + ); + } + const readers = [ + actor('active', {userActive: true}), + actor('active', {features: CAPABLE}), + actor('retired', {bot: true, userActive: true, features: CAPABLE}), + ]; + for (const reader of readers) { + expect(protectedThreadBits(reader)).toBe(THREAD_PERMISSIONS); + expect(applyProtectedRolePermissions(DEFAULT_THREAD_PERMISSIONS, MANAGE_THREADS, reader)).toBe(MANAGE_THREADS); + expect( + applyProtectedOverwriteBits( + {allow: DEFAULT_THREAD_PERMISSIONS, deny: 0n}, + {allow: 0n, deny: MANAGE_THREADS}, + reader, + ), + ).toEqual({allow: 0n, deny: MANAGE_THREADS}); + } + }); + + it('keeps VIEW_CHANNEL_MEMBERS header-gated for bots', () => { + expect(applyProtectedRolePermissions(VCM | MANAGE_THREADS, 0n, actor('active', {bot: true}))).toBe(MANAGE_THREADS); + }); +}); + +describe('thread bit writers', () => { + const parse = (raw: string | null) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}); + + afterEach(() => { + syncChannelThreadsConfig(null, parse); + }); + + it('follows the viewer rule, so bearer tokens and excluded bots cannot write', async () => { + syncChannelThreadsConfig( + JSON.stringify({ + enabled: true, + ever_enabled: true, + enabled_guild_ids: ['10'], + user_basis_points: 10000, + excluded_user_ids: ['3'], + }), + parse, + ); + const guildId = createGuildID(10n); + const writer = async (userId: bigint, bot: boolean, capable: boolean) => + ( + await resolveProtectedBitActor({ + guildId, + userId: createUserID(userId), + clientFeatures: CAPABLE, + viewer: {kind: 'user', userId: createUserID(userId), bot, capable}, + isBot: async () => bot, + }) + ).threadBits?.writer; + expect(await writer(2n, false, true)).toBe(true); + expect(await writer(2n, false, false)).toBe(false); + expect(await writer(2n, true, true)).toBe(true); + expect(await writer(3n, true, true)).toBe(false); + expect(await writer(3n, false, true)).toBe(false); + }); + + it('taints an active guild on its first protected-bit write, so a later kill retires it', async () => { + clearChannelThreadsTaintCacheForTesting(); + const guildId = createGuildID(11n); + syncChannelThreadsConfig(JSON.stringify({enabled: true, ever_enabled: true, enabled_guild_ids: ['11']}), parse); + expect(await isTainted(guildId)).toBe(false); + await resolveProtectedBitActor({ + guildId, + userId: createUserID(2n), + clientFeatures: NONE, + isBot: async () => true, + }); + clearChannelThreadsTaintCacheForTesting(); + expect(await isTainted(guildId)).toBe(true); + syncChannelThreadsConfig(JSON.stringify({enabled: false, ever_enabled: true}), parse); + expect(await resolveThreadPermissionMode(guildId)).toBe('retired'); + expect(await resolveThreadPermissionMode(createGuildID(12n))).toBe('control'); + }); + + it('masks thread bits for non-viewers of an active guild while a clean taint read is still cached', async () => { + clearChannelThreadsTaintCacheForTesting(); + const guildId = createGuildID(13n); + syncChannelThreadsConfig( + JSON.stringify({enabled: true, ever_enabled: true, enabled_guild_ids: ['13'], included_user_ids: ['2']}), + parse, + ); + expect(await isTainted(guildId)).toBe(false); + const bits = [Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS]; + const control = {kind: 'user', userId: createUserID(3n), bot: false, capable: true} as const; + const member = {kind: 'user', userId: createUserID(2n), bot: false, capable: true} as const; + expect(await shouldMaskThreadPermissionBits(guildId, control, bits)).toBe(true); + expect(await shouldMaskThreadPermissionBits(guildId, member, bits)).toBe(false); + expect(await shouldMaskThreadPermissionBits(createGuildID(14n), control, bits)).toBe(false); + const guild = {id: '13', roles: [{id: '13', permissions: bits[0]!.toString()}]} as unknown as GuildResponse; + expect((await maskGuildResponseThreadBits(guildId, control, guild)).roles?.[0]?.permissions).toBe( + Permissions.SEND_MESSAGES.toString(), + ); + expect(await maskGuildResponseThreadBits(guildId, member, guild)).toBe(guild); + }); + + it('masks the computed guild list permissions for non-viewers only', async () => { + clearChannelThreadsTaintCacheForTesting(); + syncChannelThreadsConfig( + JSON.stringify({enabled: true, ever_enabled: true, enabled_guild_ids: ['15'], included_user_ids: ['2']}), + parse, + ); + const bits = (Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS).toString(); + const guilds = [ + {id: '15', permissions: bits}, + {id: '16', permissions: bits}, + {id: '17'}, + ] as unknown as Array; + const control = {kind: 'user', userId: createUserID(3n), bot: false, capable: true} as const; + const member = {kind: 'user', userId: createUserID(2n), bot: false, capable: true} as const; + expect((await maskUserGuildsThreadBits(control, guilds)).map((guild) => guild.permissions)).toEqual([ + Permissions.SEND_MESSAGES.toString(), + bits, + undefined, + ]); + expect(await maskUserGuildsThreadBits(member, guilds)).toEqual(guilds); + syncChannelThreadsConfig(null, parse); + expect(await maskUserGuildsThreadBits(control, guilds)).toBe(guilds); + }); +}); + +describe('thread permission audit naming', () => { + it('names thread bits only outside control guilds', () => { + const before = Permissions.SEND_MESSAGES; + const after = Permissions.SEND_MESSAGES | MANAGE_THREADS; + expect(computePermissionsDiff(before, after)).toEqual({added: [], removed: []}); + expect(computePermissionsDiff(before, after, {threads: true})).toEqual({added: ['MANAGE_THREADS'], removed: []}); + }); +}); + +describe('guild MFA enforcement', () => { + it('treats MANAGE_THREADS as elevated', async () => { + const userRepository = { + findUnique: async () => ({authenticatorTypes: new Set()}), + } as unknown as IUserRepository; + const enforce = await createGuildMfaEnforcer({ + userRepository, + guildData: {mfa_level: GuildMFALevel.ELEVATED, owner_id: '1'}, + userId: createUserID(2n), + }); + expect(() => enforce(MANAGE_THREADS)).toThrow(MfaNotEnabledError); + expect(() => enforce(ThreadPermissionFlags.CREATE_PUBLIC_THREADS)).not.toThrow(); + }); +}); + +describe('thread permission seeding rules', () => { + it('clears raw thread bits on overwrites without mirroring SEND_MESSAGES', () => { + expect(seedThreadOverwriteBits(MANAGE_THREADS | Permissions.VIEW_CHANNEL)).toBe(Permissions.VIEW_CHANNEL); + expect(seedThreadOverwriteBits(Permissions.SEND_MESSAGES | MANAGE_THREADS)).toBe(Permissions.SEND_MESSAGES); + expect(seedThreadOverwriteBits(Permissions.SEND_MESSAGES) & DEFAULT_THREAD_PERMISSIONS).toBe(0n); + expect(seedThreadOverwriteBits(seedThreadOverwriteBits(Permissions.SEND_MESSAGES))).toBe(Permissions.SEND_MESSAGES); + }); + + it('mirrors SEND_MESSAGES into the thread bits on either overwrite side', () => { + expect(seedThreadOverwriteValue(Permissions.SEND_MESSAGES | MANAGE_THREADS)).toBe( + Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS, + ); + expect(seedThreadOverwriteValue(MANAGE_THREADS | Permissions.VIEW_CHANNEL)).toBe(Permissions.VIEW_CHANNEL); + expect(seedThreadOverwriteValue(seedThreadOverwriteValue(Permissions.SEND_MESSAGES))).toBe( + Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS, + ); + }); + + it('targets enabled, non-disabled guilds of an enabled config', () => { + const config = { + ...DEFAULT_CHANNEL_THREADS_CONFIG, + enabled: true, + enabled_guild_ids: ['1', '2', '3'], + disabled_guild_ids: ['3'], + }; + expect(enabledThreadGuildIds(config)).toEqual(['1', '2']); + expect(enabledThreadGuildIds({...config, enabled: false})).toEqual([]); + }); + + it('lists only guilds that became active in the landed config', () => { + const previous = {...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, enabled_guild_ids: ['1', '2', '3']}; + expect(newlyEnabledThreadGuildIds(previous, previous)).toEqual([]); + expect(newlyEnabledThreadGuildIds(previous, {...previous, enabled_guild_ids: ['1', '2', '3', '4']})).toEqual(['4']); + expect(newlyEnabledThreadGuildIds({...previous, disabled_guild_ids: ['2']}, previous)).toEqual(['2']); + expect(newlyEnabledThreadGuildIds({...previous, enabled: false}, previous)).toEqual(['1', '2', '3']); + expect(newlyEnabledThreadGuildIds(previous, {...previous, enabled: false})).toEqual([]); + }); + + it('enqueues seeds for every targeted guild without a seeded marker, so a retried update still seeds', async () => { + const addJob = vi.fn(async () => {}); + setInjectedWorkerService({addJob} as unknown as IWorkerService); + const markers = new Map([ + [ + '1', + { + guild_id: createGuildID(1n), + first_active_at: new Date(), + perms_seeded_at: new Date(), + search_backfilled_at: null, + }, + ], + [ + '2', + {guild_id: createGuildID(2n), first_active_at: new Date(), perms_seeded_at: null, search_backfilled_at: null}, + ], + ]); + const threads = { + getGuildMarker: async (guildId: GuildID) => markers.get(guildId.toString()) ?? null, + } as unknown as IThreadRepository; + const config = {...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, enabled_guild_ids: ['1', '2', '3']}; + try { + await enqueueThreadPermissionSeeds(threads, config); + await enqueueThreadPermissionSeeds(threads, config); + } finally { + setInjectedWorkerService(undefined); + } + const keys = addJob.mock.calls.map((call) => (call as unknown as [string, unknown, {jobKey: string}])[2].jobKey); + expect(keys).toEqual([ + 'seed-thread-permissions-2', + 'seed-thread-permissions-3', + 'seed-thread-permissions-2', + 'seed-thread-permissions-3', + ]); + }); +}); diff --git a/fluxer_api/src/api/guild/services/ThreadPermissionBits.ts b/fluxer_api/src/api/guild/services/ThreadPermissionBits.ts new file mode 100644 index 000000000..ff6495c05 --- /dev/null +++ b/fluxer_api/src/api/guild/services/ThreadPermissionBits.ts @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createGuildID, type GuildID, type UserID} from '@app/api/BrandedTypes'; +import { + ensureActiveGuildTainted, + everEnabled, + guildActive, + isTainted, + type ThreadViewer, + userActive, + userExcluded, + userViewerActive, + viewerActive, +} from '@app/api/experiment/ChannelThreadsGate'; +import { + CHANNEL_THREADS_CLIENT_FEATURE, + type ProtectedBitActor, + type ThreadPermissionMode, +} from '@app/api/utils/featureUtils'; +import {THREAD_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; + +export async function resolveThreadPermissionMode(guildId: GuildID): Promise { + if (guildActive(guildId)) return 'active'; + return (await isTainted(guildId)) ? 'retired' : 'control'; +} + +export async function resolveProtectedBitActor(params: { + guildId: GuildID; + userId: UserID; + clientFeatures: ReadonlySet; + viewer?: ThreadViewer; + isBot: () => Promise; +}): Promise { + const mode = await resolveThreadPermissionMode(params.guildId); + if (mode === 'control') return {clientFeatures: params.clientFeatures}; + if (mode === 'active') await ensureActiveGuildTainted(params.guildId); + return { + clientFeatures: params.clientFeatures, + threadBits: {mode, writer: mode === 'active' && (await threadBitWriter(params))}, + }; +} + +async function threadBitWriter(params: { + userId: UserID; + clientFeatures: ReadonlySet; + viewer?: ThreadViewer; + isBot: () => Promise; +}): Promise { + if (params.viewer) return userViewerActive(params.viewer); + if (await params.isBot()) return !userExcluded(params.userId); + return params.clientFeatures.has(CHANNEL_THREADS_CLIENT_FEATURE) && userActive(params.userId); +} + +export function hasThreadPermissionBits(value: bigint | null | undefined): boolean { + return value != null && (value & THREAD_PERMISSIONS) !== 0n; +} + +export function stripThreadPermissionBits(value: bigint): bigint { + return value & ~THREAD_PERMISSIONS; +} + +export async function shouldMaskThreadPermissionBits( + guildId: GuildID, + viewer: ThreadViewer | undefined, + values: Iterable, +): Promise { + if (!viewer || viewer.kind === 'system') return false; + let present = false; + for (const value of values) { + if (hasThreadPermissionBits(value)) { + present = true; + break; + } + } + if (!present) return false; + if (!guildActive(guildId) && !(await isTainted(guildId))) return false; + return !viewerActive(viewer, guildId); +} + +export async function maskChannelResponseThreadBits( + guildId: GuildID | null | undefined, + viewer: ThreadViewer | undefined, + responses: Array, +): Promise> { + if (!guildId || !everEnabled()) return responses; + const values = responses.flatMap((response) => + (response.permission_overwrites ?? []).flatMap((overwrite) => [BigInt(overwrite.allow), BigInt(overwrite.deny)]), + ); + if (!(await shouldMaskThreadPermissionBits(guildId, viewer, values))) return responses; + return responses.map((response) => + response.permission_overwrites + ? { + ...response, + permission_overwrites: response.permission_overwrites.map((overwrite) => ({ + ...overwrite, + allow: stripThreadPermissionBits(BigInt(overwrite.allow)).toString(), + deny: stripThreadPermissionBits(BigInt(overwrite.deny)).toString(), + })), + } + : response, + ); +} + +export async function maskGuildResponseThreadBits( + guildId: GuildID, + viewer: ThreadViewer, + guild: GuildResponse, +): Promise { + if (!everEnabled() || !guild.roles) return guild; + const values = guild.roles.map((role) => BigInt(role.permissions)); + if (!(await shouldMaskThreadPermissionBits(guildId, viewer, values))) return guild; + return { + ...guild, + roles: guild.roles.map((role) => ({ + ...role, + permissions: stripThreadPermissionBits(BigInt(role.permissions)).toString(), + })), + }; +} + +export async function maskUserGuildsThreadBits( + viewer: ThreadViewer, + guilds: Array, +): Promise> { + if (!everEnabled()) return guilds; + return Promise.all( + guilds.map(async (guild) => { + if (guild.permissions == null) return guild; + const permissions = BigInt(guild.permissions); + if (!(await shouldMaskThreadPermissionBits(createGuildID(BigInt(guild.id)), viewer, [permissions]))) return guild; + return {...guild, permissions: stripThreadPermissionBits(permissions).toString()}; + }), + ); +} + +export function flagThreadActiveUserGuilds(viewer: ThreadViewer, guilds: Array): Array { + if (!userViewerActive(viewer)) return guilds; + return guilds.map((guild) => (guildActive(guild.id) ? {...guild, threads_active: true} : guild)); +} diff --git a/fluxer_api/src/api/guild/services/channel/ChannelHelpers.ts b/fluxer_api/src/api/guild/services/channel/ChannelHelpers.ts index f217139d2..3b0ec1310 100644 --- a/fluxer_api/src/api/guild/services/channel/ChannelHelpers.ts +++ b/fluxer_api/src/api/guild/services/channel/ChannelHelpers.ts @@ -5,6 +5,7 @@ import type {Channel} from '@app/api/models/Channel'; import {serializeChannelForAudit as serializeChannelForAuditUtil} from '@app/api/utils/AuditSerializationUtils'; import {toIdString} from '@app/api/utils/IdUtils'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; @@ -16,7 +17,10 @@ export interface ChannelReorderOperation { function isTextLikeChannelType(type: number): boolean { return ( - type === ChannelTypes.GUILD_TEXT || type === ChannelTypes.GUILD_ANNOUNCEMENT || type === ChannelTypes.GUILD_LINK + type === ChannelTypes.GUILD_TEXT || + type === ChannelTypes.GUILD_ANNOUNCEMENT || + type === ChannelTypes.GUILD_LINK || + THREAD_ONLY_CHANNEL_TYPES.has(type) ); } diff --git a/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts b/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts index 5db66288a..73bc97f8d 100644 --- a/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts +++ b/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts @@ -4,11 +4,26 @@ import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '@app/ import {createChannelID, createRoleID, createUserID} from '@app/api/BrandedTypes'; import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; +import { + buildThreadParentPatch, + loadThreadParentConfig, + pickThreadParentSettings, + serializeThreadParentForAudit, + withThreadParentFields, + withThreadParentFieldsMany, +} from '@app/api/channel/services/thread/ThreadParentSettings'; import type {PermissionOverwrite} from '@app/api/database/types/ChannelTypes'; +import { + ensureActiveGuildTainted, + guildActive, + type ThreadViewer, + viewerActive, +} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import {ChannelHelpers, type ChannelReorderOperation} from '@app/api/guild/services/channel/ChannelHelpers'; +import {resolveProtectedBitActor} from '@app/api/guild/services/ThreadPermissionBits'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; @@ -19,6 +34,7 @@ import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder' import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {Channel} from '@app/api/models/Channel'; import {ChannelPermissionOverwrite} from '@app/api/models/ChannelPermissionOverwrite'; +import {applyProtectedOverwriteBits, permissionWriteMask, protectedThreadBits} from '@app/api/utils/featureUtils'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {ContentWarningLevel, GuildFeatures, resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants'; @@ -27,6 +43,8 @@ import { MAX_GUILD_CHANNELS, VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT, } from '@fluxer/constants/src/LimitConstants'; +import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; +import {withImplicitThreadBits} from '@fluxer/constants/src/ThreadPermissionUtils'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {MaxCategoryChannelsError} from '@fluxer/errors/src/domains/channel/MaxCategoryChannelsError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; @@ -62,11 +80,13 @@ export class ChannelOperationsService { guildId: GuildID; data: ChannelCreateRequest; requestCache: RequestCache; + clientFeatures?: ReadonlySet; + viewer?: ThreadViewer; }, auditLogReason?: string | null, ): Promise { await this.ensureGuildHasCapacity(params.guildId); - const channels = await this.channelRepository.listGuildChannels(params.guildId); + const channels = await this.channelRepository.listGuildChannels(params.guildId, 'enrolled'); const parentId = params.data.parent_id ? createChannelID(params.data.parent_id) : null; const parentChannel = this.validateParentCategory({ parentId, @@ -91,25 +111,56 @@ export class ChannelOperationsService { userId: params.userId, channelId: parentId ?? undefined, }); - for (const overwrite of requestedOverwrites) { - const allowPerms = (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS; - if ((allowPerms & ~basePermissions) !== 0n) { - throw new MissingPermissionsError(); + const actor = await resolveProtectedBitActor({ + guildId: params.guildId, + userId: params.userId, + clientFeatures: params.clientFeatures ?? new Set(), + viewer: params.viewer, + isBot: async () => (params.viewer?.kind === 'user' ? params.viewer.bot : false), + }); + if (!actor.threadBits) { + for (const overwrite of requestedOverwrites) { + const allowPerms = (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS; + if ((allowPerms & ~basePermissions) !== 0n) { + throw new MissingPermissionsError(); + } } + permissionOverwrites = new Map( + requestedOverwrites.map((overwrite) => { + const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id); + return [ + targetId, + new ChannelPermissionOverwrite({ + type: overwrite.type, + allow_: overwrite.allow ? BigInt(overwrite.allow) : 0n, + deny_: overwrite.deny ? BigInt(overwrite.deny) : 0n, + }).toPermissionOverwrite(), + ]; + }), + ); + } else { + const writeMask = permissionWriteMask(actor) & ~protectedThreadBits(actor); + const effectiveBase = withImplicitThreadBits(basePermissions); + for (const overwrite of requestedOverwrites) { + const allowPerms = (overwrite.allow ? BigInt(overwrite.allow) : 0n) & writeMask; + if ((allowPerms & ~effectiveBase) !== 0n) { + throw new MissingPermissionsError(); + } + } + permissionOverwrites = new Map( + requestedOverwrites.map((overwrite) => { + const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id); + return [ + targetId, + new ChannelPermissionOverwrite({ + type: overwrite.type, + allow_: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & writeMask, + deny_: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & writeMask, + }).toPermissionOverwrite(), + ]; + }), + ); } - permissionOverwrites = new Map( - requestedOverwrites.map((overwrite) => { - const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id); - return [ - targetId, - new ChannelPermissionOverwrite({ - type: overwrite.type, - allow_: overwrite.allow ? BigInt(overwrite.allow) : 0n, - deny_: overwrite.deny ? BigInt(overwrite.deny) : 0n, - }).toPermissionOverwrite(), - ]; - }), - ); } else if (parentChannel?.permissionOverwrites) { permissionOverwrites = new Map( Array.from(parentChannel.permissionOverwrites.entries()).map(([targetId, overwrite]) => [ @@ -120,18 +171,31 @@ export class ChannelOperationsService { } let channelName = params.data.name; let guildFeatures: Array | null = null; - if ( + const textNamed = params.data.type === ChannelTypes.GUILD_TEXT || params.data.type === ChannelTypes.GUILD_ANNOUNCEMENT || - params.data.type === ChannelTypes.GUILD_VOICE - ) { + THREAD_ONLY_CHANNEL_TYPES.has(params.data.type); + if (textNamed || params.data.type === ChannelTypes.GUILD_VOICE) { const guildData = await this.gatewayService.getGuildData({ guildId: params.guildId, userId: params.userId, }); guildFeatures = guildData.features; } - if (params.data.type === ChannelTypes.GUILD_TEXT || params.data.type === ChannelTypes.GUILD_ANNOUNCEMENT) { + const parentSettings = pickThreadParentSettings(params.data.type, params.data); + const parentPatch = + parentSettings && guildActive(params.guildId) + ? await buildThreadParentPatch({ + channelType: params.data.type, + guildId: params.guildId, + input: parentSettings, + current: null, + guildRepository: this.guildRepository, + generateId: () => this.snowflakeService.generate(), + }) + : null; + if (THREAD_ONLY_CHANNEL_TYPES.has(params.data.type)) await ensureActiveGuildTainted(params.guildId); + if (textNamed) { const hasFlexibleNamesEnabled = (guildFeatures ?? []).includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES); if (!hasFlexibleNamesEnabled) { channelName = ChannelNameType.parse(channelName); @@ -182,6 +246,7 @@ export class ChannelOperationsService { indexed_at: null, version: 1, }); + if (parentPatch) await this.channelRepository.threads.patchParentConfig(params.guildId, channel.id, parentPatch); await this.dispatchChannelCreate({guildId: params.guildId, channel, requestCache: params.requestCache}); await this.recordAuditLog({ guildId: params.guildId, @@ -190,7 +255,13 @@ export class ChannelOperationsService { targetId: channel.id, auditLogReason: auditLogReason ?? null, metadata: {name: channel.name ?? '', type: channel.type.toString()}, - changes: this.guildAuditLogService.computeChanges(null, ChannelHelpers.serializeChannelForAudit(channel)), + changes: this.guildAuditLogService.computeChanges(null, { + ...ChannelHelpers.serializeChannelForAudit(channel), + ...serializeThreadParentForAudit( + channel.type, + await loadThreadParentConfig(this.channelRepository.threads, channel), + ), + }), }); if (channel.permissionOverwrites.size > 0) { await this.guildAuditLogService.recordPermissionOverwriteDiff({ @@ -202,12 +273,17 @@ export class ChannelOperationsService { reason: auditLogReason ?? null, }); } - return await mapChannelToResponse({ + return await withThreadParentFields( + this.channelRepository.threads, channel, - currentUserId: null, - userCacheService: this.userCacheService, - requestCache: params.requestCache, - }); + await mapChannelToResponse({ + channel, + currentUserId: null, + userCacheService: this.userCacheService, + requestCache: params.requestCache, + }), + params.viewer, + ); } async updateChannelPositionsLocked(params: { @@ -230,7 +306,7 @@ export class ChannelOperationsService { async sanitizeTextChannelNames(params: {guildId: GuildID; requestCache: RequestCache}): Promise { const {guildId, requestCache} = params; - const channels = await this.channelRepository.listGuildChannels(guildId); + const channels = await this.channelRepository.listGuildChannels(guildId, 'enrolled'); let hasChanges = false; const updatedChannels: Array = []; for (const channel of channels) { @@ -270,6 +346,8 @@ export class ChannelOperationsService { }>; requestCache: RequestCache; auditLogReason: string | null; + clientFeatures?: ReadonlySet; + viewer?: ThreadViewer; }): Promise { const {guildId, userId, updates, requestCache} = params; const lockKey = `guild:${guildId}:channel-positions`; @@ -278,7 +356,8 @@ export class ChannelOperationsService { throw new ResourceLockedError(); } try { - const allChannels = await this.channelRepository.listGuildChannels(guildId); + const hideThreadOnly = hidesThreadOnlyChannels(params.viewer, guildId); + const allChannels = await this.listPositionableChannels(guildId, hideThreadOnly); const channelMap = new Map(allChannels.map((ch) => [ch.id, ch])); for (const update of updates) { if (!channelMap.has(update.channelId)) { @@ -299,6 +378,9 @@ export class ChannelOperationsService { userId, update, requestCache, + clientFeatures: params.clientFeatures, + viewer: params.viewer, + hideThreadOnly, }); } } finally { @@ -306,6 +388,11 @@ export class ChannelOperationsService { } } + private async listPositionableChannels(guildId: GuildID, hideThreadOnly: boolean): Promise> { + const channels = await this.channelRepository.listGuildChannels(guildId, 'enrolled'); + return hideThreadOnly ? channels.filter((ch) => !ch.isThreadOnly()) : channels; + } + private async applySinglePositionUpdate(params: { guildId: GuildID; userId: UserID; @@ -317,9 +404,12 @@ export class ChannelOperationsService { lockPermissions: boolean; }; requestCache: RequestCache; + clientFeatures?: ReadonlySet; + viewer?: ThreadViewer; + hideThreadOnly: boolean; }): Promise { const {guildId, update, requestCache} = params; - const allChannels = await this.channelRepository.listGuildChannels(guildId); + const allChannels = await this.listPositionableChannels(guildId, params.hideThreadOnly); const channelMap = new Map(allChannels.map((ch) => [ch.id, ch])); const target = channelMap.get(update.channelId); if (!target) { @@ -374,6 +464,8 @@ export class ChannelOperationsService { userId: params.userId, channelId: target.id, parentId: desiredParent, + clientFeatures: params.clientFeatures, + viewer: params.viewer, }); } } @@ -383,6 +475,8 @@ export class ChannelOperationsService { userId: UserID; channelId: ChannelID; parentId: ChannelID; + clientFeatures?: ReadonlySet; + viewer?: ThreadViewer; }): Promise { const parent = await this.channelRepository.findUnique(params.parentId); if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return; @@ -396,18 +490,45 @@ export class ChannelOperationsService { if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) { throw new MissingPermissionsError(); } + const actor = await resolveProtectedBitActor({ + guildId: params.guildId, + userId: params.userId, + clientFeatures: params.clientFeatures ?? new Set(), + viewer: params.viewer, + isBot: async () => (params.viewer?.kind === 'user' ? params.viewer.bot : false), + }); + const incomingOverwrites = actor.threadBits + ? new Map( + Array.from(parent.permissionOverwrites.entries()).map(([targetId, overwrite]) => { + const existing = child.permissionOverwrites.get(targetId); + const protectedBits = applyProtectedOverwriteBits( + {allow: overwrite.allow, deny: overwrite.deny}, + {allow: existing?.allow ?? 0n, deny: existing?.deny ?? 0n}, + actor, + ); + return [ + targetId, + new ChannelPermissionOverwrite({ + type: overwrite.type, + allow_: protectedBits.allow, + deny_: protectedBits.deny, + }), + ]; + }), + ) + : parent.permissionOverwrites; for (const [targetId, existing] of child.permissionOverwrites) { - const incomingDeny = parent.permissionOverwrites.get(targetId)?.deny ?? 0n; + const incomingDeny = incomingOverwrites.get(targetId)?.deny ?? 0n; if ((existing.deny & ~incomingDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError(); } - for (const [targetId, incoming] of parent.permissionOverwrites) { + for (const [targetId, incoming] of incomingOverwrites) { const existingAllow = child.permissionOverwrites.get(targetId)?.allow ?? 0n; if ((incoming.allow & ~existingAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError(); } await this.channelRepository.upsert({ ...child.toRow(), permission_overwrites: new Map( - Array.from(parent.permissionOverwrites.entries()).map(([targetId, overwrite]) => [ + Array.from(incomingOverwrites.entries()).map(([targetId, overwrite]) => [ targetId, overwrite.toPermissionOverwrite(), ]), @@ -421,7 +542,7 @@ export class ChannelOperationsService { requestCache: RequestCache; }): Promise { const {guildId, operation, requestCache} = params; - const allChannels = await this.channelRepository.listGuildChannels(guildId); + const allChannels = await this.channelRepository.listGuildChannels(guildId, 'enrolled'); const planResult = computeGuildChannelReorderPlan({channels: allChannels, operation}); if (!planResult.ok) { this.throwReorderPlanError(planResult.code, operation); @@ -464,7 +585,7 @@ export class ChannelOperationsService { } } await Promise.all(updatePromises); - const updatedChannels = await this.channelRepository.listGuildChannels(guildId); + const updatedChannels = await this.channelRepository.listGuildChannels(guildId, 'enrolled'); await this.dispatchChannelUpdateBulk({guildId, channels: updatedChannels, requestCache}); } @@ -581,12 +702,16 @@ export class ChannelOperationsService { await this.gatewayService.dispatchGuild({ guildId, event: 'CHANNEL_CREATE', - data: await mapChannelToResponse({ + data: await withThreadParentFields( + this.channelRepository.threads, channel, - currentUserId: null, - userCacheService: this.userCacheService, - requestCache, - }), + await mapChannelToResponse({ + channel, + currentUserId: null, + userCacheService: this.userCacheService, + requestCache, + }), + ), }); } @@ -612,7 +737,9 @@ export class ChannelOperationsService { await this.gatewayService.dispatchGuild({ guildId, event: 'CHANNEL_UPDATE_BULK', - data: {channels: channelResponses}, + data: { + channels: await withThreadParentFieldsMany(this.channelRepository.threads, guildId, channels, channelResponses), + }, }); } @@ -643,3 +770,7 @@ export class ChannelOperationsService { } } } + +function hidesThreadOnlyChannels(viewer: ThreadViewer | undefined, guildId: GuildID): boolean { + return viewer != null && guildActive(guildId) && !viewerActive(viewer, guildId); +} diff --git a/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts b/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts index 3a255fb33..5761c2816 100644 --- a/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts +++ b/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts @@ -12,6 +12,7 @@ import {getContentMessage} from '@app/api/content_i18n/ContentI18n'; import {BatchBuilder} from '@app/api/database/CassandraQueryExecution'; import type {PermissionOverwrite} from '@app/api/database/types/ChannelTypes'; import type {GuildRow} from '@app/api/database/types/GuildTypes'; +import {guildActive, isTainted} from '@app/api/experiment/ChannelThreadsGate'; import {mapGuildToGuildResponse, mapGuildToPartialResponse} from '@app/api/guild/GuildModel'; import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; @@ -26,15 +27,18 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils'; import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import {getKVThreadAutoArchiveQueue} from '@app/api/middleware/ServiceSingletons'; import {Guild} from '@app/api/models/Guild'; import type {User} from '@app/api/models/User'; import {getGuildSearchService} from '@app/api/SearchFactory'; import type {GuildDiscoveryContext} from '@app/api/search/guild/GuildSearchSerializer'; import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup'; +import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService'; import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '@app/api/Tables'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {mapUserSettingsToResponse} from '@app/api/user/UserMappers'; import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '@app/api/user/utils/GuildFolderUtils'; +import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils'; import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {ALL_PERMISSIONS, ChannelTypes, DEFAULT_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants'; @@ -56,6 +60,11 @@ import { VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT, VOICE_CHANNEL_USER_LIMIT_MAX, } from '@fluxer/constants/src/LimitConstants'; +import { + DEFAULT_THREAD_PERMISSIONS, + THREAD_AWARE_ALL_PERMISSIONS, + THREAD_PERMISSIONS, +} from '@fluxer/constants/src/ThreadPermissionUtils'; import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError'; @@ -166,6 +175,7 @@ function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { } const TEMPLATE_AFK_TIMEOUT_MIN_SECONDS = 60; +const THREAD_PURGE_CONCURRENCY = 8; const TEMPLATE_AFK_TIMEOUT_MAX_SECONDS = 3600; const THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE = 13; @@ -413,6 +423,9 @@ export class GuildOperationsService { ); batch.addPrepared(GuildMembersByUserId.insert({user_id: user.id, guild_id: guildId})); await batch.execute(); + if (guildActive(guildId)) { + await this.channelRepository.threads.ensureGuildMarker(guildId, {permsSeededAt: new Date()}); + } const guild = new Guild(guildData); await this.gatewayService.startGuild(guildId); await this.gatewayService.joinGuild({userId: user.id, guildId}); @@ -755,7 +768,7 @@ export class GuildOperationsService { if (!guild) { throw new UnknownGuildError(); } - const channels = await this.channelRepository.listGuildChannels(guildId); + const channels = await this.channelRepository.listGuildChannels(guildId, 'complete'); for (const channel of channels) { await scheduleDeletedChannelFollowerRemoval({channel, crossposts: this.channelRepository.crossposts, copyMode}); } @@ -788,6 +801,7 @@ export class GuildOperationsService { await Promise.all(invites.map((invite) => this.inviteRepository.delete(invite.code))); const webhooks = await this.webhookRepository.listByGuild(guildId); await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id))); + const threadIds = await this.channelRepository.threads.listGuildThreadIds(guildId, {parents: channels}); for (const channel of channels) { await this.channelService.attachments.purgeChannelAttachments(channel); } @@ -799,6 +813,24 @@ export class GuildOperationsService { if (discoveryRow) { await this.discoveryRepository.deleteByGuildId(guildId, discoveryRow.status, discoveryRow.applied_at); } + if (threadIds.length > 0) { + const threadChannels = new Map( + (await this.channelRepository.listChannels(threadIds)).map((channel) => [channel.id, channel]), + ); + await mapWithConcurrency(threadIds, THREAD_PURGE_CONCURRENCY, async (threadId) => { + const thread = threadChannels.get(threadId); + if (thread) { + await deleteChannelMessageSearchDocuments(threadId, {context: {source: 'guild_delete'}}); + await this.channelService.attachments.purgeChannelAttachments(thread); + } + await this.channelRepository.threads.purgeThread(threadId); + }); + await deleteThreadSearchDocuments(threadIds); + } + if (await isTainted(guildId, {fresh: true})) { + await this.channelRepository.threads.purgeGuild(guildId); + await getKVThreadAutoArchiveQueue().removeGuild(guildId); + } await this.guildRepository.delete(guildId, guild.ownerId); await this.gatewayService.stopGuild(guildId); const guildSearchService = getGuildSearchService(); @@ -899,7 +931,7 @@ export class GuildOperationsService { guild_id: guildId, role_id: guildIdToRoleId(guildId), name: '@everyone', - permissions: DEFAULT_PERMISSIONS, + permissions: guildActive(guildId) ? DEFAULT_PERMISSIONS | DEFAULT_THREAD_PERMISSIONS : DEFAULT_PERMISSIONS, position: 0, hoist_position: null, color: 0, @@ -975,9 +1007,19 @@ export class GuildOperationsService { '[GuildOperationsService] Template import skipped unsupported channel types', ); } + const threadsActive = guildActive(guildId); + const permissionMask = threadsActive ? THREAD_AWARE_ALL_PERMISSIONS : ALL_PERMISSIONS; + const templateEveryonePermissions = + this.parseTemplatePermissionBitfield( + everyoneRole?.permissions_new ?? everyoneRole?.permissions, + permissionMask, + ) || DEFAULT_PERMISSIONS; const everyonePermissions = - this.parseTemplatePermissionBitfield(everyoneRole?.permissions_new ?? everyoneRole?.permissions) || - DEFAULT_PERMISSIONS; + threadsActive && + (templateEveryonePermissions & THREAD_PERMISSIONS) === 0n && + (templateEveryonePermissions & Permissions.SEND_MESSAGES) !== 0n + ? templateEveryonePermissions | DEFAULT_THREAD_PERMISSIONS + : templateEveryonePermissions; batch.addPrepared( GuildRoles.insert({ guild_id: guildId, @@ -1005,7 +1047,7 @@ export class GuildOperationsService { guild_id: guildId, role_id: roleId, name: role.name, - permissions: this.parseTemplatePermissionBitfield(role.permissions_new ?? role.permissions), + permissions: this.parseTemplatePermissionBitfield(role.permissions_new ?? role.permissions, permissionMask), position: nextRolePosition, hoist_position: null, color: role.color ?? 0, @@ -1033,8 +1075,8 @@ export class GuildOperationsService { if (overwrite.type !== 0) continue; const mappedRoleId = roleIdMap.get(this.getTemplateEntityKey(overwrite.id)); if (!mappedRoleId) continue; - const allow = this.parseTemplatePermissionBitfield(overwrite.allow); - const deny = this.parseTemplatePermissionBitfield(overwrite.deny); + const allow = this.parseTemplatePermissionBitfield(overwrite.allow, permissionMask); + const deny = this.parseTemplatePermissionBitfield(overwrite.deny, permissionMask); permissionOverwrites.set(mappedRoleId, { type: overwrite.type, allow_: allow, @@ -1140,10 +1182,10 @@ export class GuildOperationsService { return {systemChannelId}; } - private parseTemplatePermissionBitfield(value: string | number | undefined): bigint { + private parseTemplatePermissionBitfield(value: string | number | undefined, mask: bigint): bigint { const valueToParse = value ?? '0'; try { - return BigInt(valueToParse) & ALL_PERMISSIONS; + return BigInt(valueToParse) & mask; } catch { throw new GuildTemplateInvalidError(); } diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts index cca42ee18..fcd0d3636 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts @@ -33,8 +33,9 @@ import type {UserGuildSettings} from '@app/api/models/UserGuildSettings'; import type {UserSettings} from '@app/api/models/UserSettings'; import {assertAccountNotLimited} from '@app/api/user/AccountLimit'; import type {IUserRepository} from '@app/api/user/IUserRepository'; +import {dispatchUserGuildSettingsUpdate} from '@app/api/user/UserGuildSettingsThreadView'; import {isProfileSubstringExempt} from '@app/api/user/UserHelpers'; -import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@app/api/user/UserMappers'; +import {mapUserSettingsToResponse} from '@app/api/user/UserMappers'; import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '@app/api/user/utils/GuildFolderUtils'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; @@ -973,19 +974,7 @@ export class GuildMemberOperationsService { userId: UserID; settings: UserGuildSettings; }): Promise { - const payload = mapUserGuildSettingsToResponse(settings); - await this.gatewayService.dispatchPresence({ - userId, - event: 'USER_GUILD_SETTINGS_UPDATE', - data: payload, - }); - if (payload.guild_id !== null) { - await this.gatewayService.syncPushUserGuildSettings({ - userId, - guildId: settings.guildId, - settings: payload, - }); - } + await dispatchUserGuildSettingsUpdate(this.gatewayService, userId, settings); } private async rollbackPreparedAssets(preparedAssets: PreparedMemberAssets): Promise { diff --git a/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts b/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts index c80612240..4064b1696 100644 --- a/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts +++ b/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts @@ -4,6 +4,12 @@ import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils' import type {GuildID} from '@app/api/BrandedTypes'; import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils'; import {createPermissionOverwrite} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; import {createChannel, createGuild, getUserGuilds} from '@app/api/guild/tests/GuildTestUtils'; import {setInjectedGatewayService} from '@app/api/middleware/ServiceRegistry'; import {getGuildRepository} from '@app/api/middleware/ServiceSingletons'; @@ -16,7 +22,7 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest import syncDiscoveryIndex from '@app/api/worker/tasks/SyncDiscoveryIndex'; import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {DiscoveryCategories, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants'; import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants'; import type { @@ -112,6 +118,7 @@ describe('Discovery Search and Join', () => { }); afterEach(async () => { clearWorkerDependencies(); + resetChannelThreadsConfig(); await harness?.shutdown(); }); describe('categories', () => { @@ -561,6 +568,40 @@ describe('Discovery Search and Join', () => { .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE) .execute(); }); + test('should only preview a forum for viewers in the threads experiment', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const {ownerToken, guildId} = await createListedGuild('Forum Guild'); + const forum = await threadsRequest<{id: string}>(harness, ownerToken) + .post(`/guilds/${guildId}/channels`) + .body({name: 'forum', type: ChannelTypes.GUILD_FORUM}) + .execute(); + const viewer = await createTestAccount(harness); + const preview = await threadsRequest(harness, viewer.token) + .get(`/discovery/guilds/${guildId}/channels/${forum.id}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(preview.channel.type).toBe(ChannelTypes.GUILD_FORUM); + await createBuilder(harness, viewer.token) + .get(`/discovery/guilds/${guildId}/channels/${forum.id}`) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE) + .execute(); + }); + test('should never preview a thread', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const {ownerToken, guildId, channelId} = await createListedGuild('Thread Guild'); + const viewer = await createTestAccount(harness); + for (const type of [ChannelTypes.PUBLIC_THREAD, ChannelTypes.PRIVATE_THREAD]) { + const thread = await threadsRequest<{id: string}>(harness, ownerToken) + .post(`/channels/${channelId}/threads`) + .body({name: 'secret plans', type}) + .expect(201) + .execute(); + await threadsRequest(harness, viewer.token) + .get(`/discovery/guilds/${guildId}/channels/${thread.id}`) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE) + .execute(); + } + }); test('should not preview a guild that is not listed', async () => { const owner = await createTestAccount(harness); const guild = await createGuild(harness, owner.token, 'Unlisted Guild'); diff --git a/fluxer_api/src/api/guild/tests/GuildAuditLogEntryMapper.test.ts b/fluxer_api/src/api/guild/tests/GuildAuditLogEntryMapper.test.ts index a523ae711..b5cba6da0 100644 --- a/fluxer_api/src/api/guild/tests/GuildAuditLogEntryMapper.test.ts +++ b/fluxer_api/src/api/guild/tests/GuildAuditLogEntryMapper.test.ts @@ -6,6 +6,7 @@ import { GUILD_AUDIT_INTERNAL_CHANGE_KEYS, isNoopGuildAuditLog, mapGuildAuditLogEntry, + maskThreadAuditLog, } from '@app/api/guild/GuildAuditLogEntryMapper'; import type {GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes'; import {GuildAuditLog} from '@app/api/models/GuildAuditLog'; @@ -466,3 +467,52 @@ describe('isNoopGuildAuditLog', () => { expect(isNoopGuildAuditLog(actionType, null)).toBe(false); }); }); + +describe('maskThreadAuditLog', () => { + const MANAGE_THREADS = 1n << 34n; + const SEND = 1n << 11n; + + it('strips thread bits from overwrite allow and deny', () => { + const log = makeLog({ + actionType: AuditLogActionType.CHANNEL_OVERWRITE_UPDATE, + targetId: TARGET_ID, + changes: [ + {key: 'allow', old_value: '0', new_value: (SEND | MANAGE_THREADS).toString()}, + {key: 'deny', old_value: '0', new_value: MANAGE_THREADS.toString()}, + ], + }); + expect(maskThreadAuditLog(log).changes).toEqual([{key: 'allow', old_value: '0', new_value: SEND.toString()}]); + }); + + it('drops thread-only role changes and thread names from the permissions diff', () => { + const log = makeLog({ + actionType: AuditLogActionType.ROLE_UPDATE, + targetId: TARGET_ID, + changes: [ + {key: 'permissions', old_value: (SEND | MANAGE_THREADS).toString(), new_value: SEND.toString()}, + {key: 'permissions_diff', new_value: {added: [], removed: ['MANAGE_THREADS']}}, + ], + }); + const masked = maskThreadAuditLog(log); + expect(masked.changes).toBeNull(); + expect(isNoopGuildAuditLog(masked.actionType, masked.changes)).toBe(true); + }); + + it('drops thread surface keys from channel changes and leaves other entries untouched', () => { + const channelLog = makeLog({ + actionType: AuditLogActionType.CHANNEL_UPDATE, + targetId: TARGET_ID, + changes: [ + {key: 'name', old_value: 'a', new_value: 'b'}, + {key: 'default_auto_archive_duration', old_value: 60, new_value: 1440}, + ], + }); + expect(maskThreadAuditLog(channelLog).changes).toEqual([{key: 'name', old_value: 'a', new_value: 'b'}]); + const memberLog = makeLog({ + actionType: AuditLogActionType.MEMBER_UPDATE, + targetId: TARGET_ID, + changes: [{key: 'flags', old_value: 0, new_value: 1}], + }); + expect(maskThreadAuditLog(memberLog)).toBe(memberLog); + }); +}); diff --git a/fluxer_api/src/api/guild/tests/GuildChannelPositions.test.ts b/fluxer_api/src/api/guild/tests/GuildChannelPositions.test.ts index 64b019d49..9f224daee 100644 --- a/fluxer_api/src/api/guild/tests/GuildChannelPositions.test.ts +++ b/fluxer_api/src/api/guild/tests/GuildChannelPositions.test.ts @@ -318,7 +318,7 @@ describe('Guild Channel Positions', () => { {id: coopVoice.id, parent_id: coopGames.id}, ]); const channelRepository = new ChannelRepository(); - const channelsBeforeMove = await channelRepository.listGuildChannels(createGuildID(BigInt(guild.id))); + const channelsBeforeMove = await channelRepository.listGuildChannels(createGuildID(BigInt(guild.id)), 'enrolled'); const storedCoopText = channelsBeforeMove.find((channel) => channel.id.toString() === coopText.id); const storedCoopVoice = channelsBeforeMove.find((channel) => channel.id.toString() === coopVoice.id); expect(storedCoopText).toBeDefined(); diff --git a/fluxer_api/src/api/guild/tests/GuildChannelPositionsThreadGate.test.ts b/fluxer_api/src/api/guild/tests/GuildChannelPositionsThreadGate.test.ts new file mode 100644 index 000000000..57d282cc7 --- /dev/null +++ b/fluxer_api/src/api/guild/tests/GuildChannelPositionsThreadGate.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; + +interface ValidationResponse { + code: string; + errors: Array<{path: string; code: string}>; +} + +describe('guild channel positions with thread-only channels', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup() { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + await ensureSessionStarted(harness, owner.token); + const guild = await createGuild(harness, owner.token, 'positions'); + const category = await createChannel(harness, owner.token, guild.id, 'cat', ChannelTypes.GUILD_CATEGORY); + const text = await createChannel(harness, owner.token, guild.id, 'text'); + const forum = await threadsRequest(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'forum', type: ChannelTypes.GUILD_FORUM}) + .execute(); + return {owner, guildId: guild.id, categoryId: category.id, textId: text.id, forumId: forum.id}; + } + + async function rejected(token: string, guildId: string, body: Array>) { + const response = await createBuilder(harness, token) + .patch(`/guilds/${guildId}/channels`) + .body(body) + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + return response.errors[0]; + } + + test('a non-viewer naming a forum id gets the same errors as an unknown id', async () => { + const s = await setup(); + const unknownId = '1'; + const asId = await rejected(s.owner.token, s.guildId, [ + {id: s.forumId, parent_id: s.categoryId, lock_permissions: true}, + ]); + const unknownAsId = await rejected(s.owner.token, s.guildId, [ + {id: unknownId, parent_id: s.categoryId, lock_permissions: true}, + ]); + expect(asId).toEqual(unknownAsId); + expect(asId?.code).toBe(ValidationErrorCodes.CHANNEL_NOT_FOUND); + + const asParent = await rejected(s.owner.token, s.guildId, [{id: s.textId, parent_id: s.forumId}]); + expect(asParent).toEqual(await rejected(s.owner.token, s.guildId, [{id: s.textId, parent_id: unknownId}])); + expect(asParent?.code).toBe(ValidationErrorCodes.INVALID_PARENT_CHANNEL); + + const asSibling = await rejected(s.owner.token, s.guildId, [{id: s.textId, preceding_sibling_id: s.forumId}]); + expect(asSibling?.path).toBe('preceding_sibling_id'); + expect(asSibling?.code).toBe(ValidationErrorCodes.INVALID_CHANNEL_ID); + + const forum = await threadsRequest(harness, s.owner.token).get(`/channels/${s.forumId}`).execute(); + expect(forum.parent_id ?? null).toBeNull(); + }); + + test('a viewer can still move a forum', async () => { + const s = await setup(); + await threadsRequest(harness, s.owner.token) + .patch(`/guilds/${s.guildId}/channels`) + .body([{id: s.forumId, parent_id: s.categoryId}]) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + const forum = await threadsRequest(harness, s.owner.token).get(`/channels/${s.forumId}`).execute(); + expect(forum.parent_id).toBe(s.categoryId); + }); +}); diff --git a/fluxer_api/src/api/guild/tests/ThreadPermissionBitsGate.test.ts b/fluxer_api/src/api/guild/tests/ThreadPermissionBitsGate.test.ts new file mode 100644 index 000000000..cc6870823 --- /dev/null +++ b/fluxer_api/src/api/guild/tests/ThreadPermissionBitsGate.test.ts @@ -0,0 +1,568 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID, createRoleID} from '@app/api/BrandedTypes'; +import {authorizeBot, createTestBotAccount} from '@app/api/bot/tests/BotTestUtils'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {fetchOne} from '@app/api/database/CassandraQueryExecution'; +import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes'; +import { + clearChannelThreadsTaintCacheForTesting, + syncChannelThreadsConfig, +} from '@app/api/experiment/ChannelThreadsGate'; +import {GuildRoleRepository} from '@app/api/guild/repositories/GuildRoleRepository'; +import { + acceptInvite, + addMemberRole, + createChannelInvite, + createGuild, + createRole, + getChannel, + updateRolePositions, +} from '@app/api/guild/tests/GuildTestUtils'; +import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; +import {GuildThreadState} from '@app/api/Tables'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {ChannelTypes, DEFAULT_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import { + DEFAULT_THREAD_PERMISSIONS, + THREAD_PERMISSIONS, + ThreadPermissionFlags, +} from '@fluxer/constants/src/ThreadPermissionUtils'; +import { + type ChannelThreadsConfig, + ChannelThreadsConfigSchema, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; +import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest'; + +const FEATURES = 'X-Fluxer-Features'; +const CAPABLE = 'channel_threads'; +const ACTIVE: Partial = { + enabled: true, + guild_basis_points: 10000, + user_basis_points: 10000, +}; + +const FETCH_MARKER = GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1}); + +async function setConfig(patch: Partial): Promise { + await getInstanceConfigRepository().updateChannelThreadsConfig((current) => + ChannelThreadsConfigSchema.parse({ + ...patch, + ever_enabled: current.ever_enabled || patch.enabled === true, + config_version: current.config_version + 1, + }), + ); + clearChannelThreadsTaintCacheForTesting(); +} + +function bits(value: string | undefined): bigint { + return BigInt(value ?? '0'); +} + +describe('thread permission bits across guild modes', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + clearChannelThreadsTaintCacheForTesting(); + }); + + afterEach(() => { + syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + clearChannelThreadsTaintCacheForTesting(); + }); + + afterAll(async () => { + await harness?.shutdown(); + }); + + async function listRoles(token: string, guildId: string, capable: boolean): Promise> { + const builder = createBuilder>(harness, token).get(`/guilds/${guildId}/roles`); + return (capable ? builder.header(FEATURES, CAPABLE) : builder).execute(); + } + + async function roleBits(token: string, guildId: string, roleId: string): Promise { + const roles = await listRoles(token, guildId, true); + return bits(roles.find((role) => role.id === roleId)?.permissions); + } + + test('control guilds keep today behaviour for requested and stored thread bits', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Control'); + const everyone = await roleBits(owner.token, guild.id, guild.id); + expect(everyone).toBe(DEFAULT_PERMISSIONS); + expect(await fetchOne(FETCH_MARKER, {guild_id: createGuildID(BigInt(guild.id))})).toBeNull(); + const role = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/roles`) + .header(FEATURES, CAPABLE) + .body({name: 'r', permissions: (Permissions.SEND_MESSAGES | ThreadPermissionFlags.MANAGE_THREADS).toString()}) + .execute(); + expect(bits(role.permissions)).toBe(Permissions.SEND_MESSAGES); + const roleRepository = new GuildRoleRepository(); + const stored = await roleRepository.getRole(createRoleID(BigInt(role.id)), createGuildID(BigInt(guild.id))); + await roleRepository.upsertRole({...stored!.toRow(), permissions: Permissions.SEND_MESSAGES | (1n << 35n)}); + expect(await roleBits(owner.token, guild.id, role.id)).toBe(Permissions.SEND_MESSAGES | (1n << 35n)); + expect(bits((await listRoles(owner.token, guild.id, false)).find((r) => r.id === role.id)?.permissions)).toBe( + Permissions.SEND_MESSAGES | (1n << 35n), + ); + const updated = await createBuilder(harness, owner.token) + .patch(`/guilds/${guild.id}/roles/${role.id}`) + .body({permissions: Permissions.VIEW_CHANNEL.toString()}) + .execute(); + expect(bits(updated.permissions)).toBe(Permissions.VIEW_CHANNEL); + }); + + test('active guilds seed @everyone, write bits for capable users and restore them for others', async () => { + await setConfig(ACTIVE); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Active'); + expect(await roleBits(owner.token, guild.id, guild.id)).toBe(DEFAULT_PERMISSIONS | DEFAULT_THREAD_PERMISSIONS); + const marker = await fetchOne(FETCH_MARKER, {guild_id: createGuildID(BigInt(guild.id))}); + expect(marker?.perms_seeded_at).toBeInstanceOf(Date); + const role = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/roles`) + .header(FEATURES, CAPABLE) + .body({name: 'mods', permissions: (Permissions.SEND_MESSAGES | ThreadPermissionFlags.MANAGE_THREADS).toString()}) + .execute(); + expect(bits(role.permissions)).toBe(Permissions.SEND_MESSAGES | ThreadPermissionFlags.MANAGE_THREADS); + const legacy = await createBuilder(harness, owner.token) + .patch(`/guilds/${guild.id}/roles/${role.id}`) + .body({permissions: (Permissions.VIEW_CHANNEL | ThreadPermissionFlags.CREATE_PUBLIC_THREADS).toString()}) + .execute(); + expect(bits(legacy.permissions) & THREAD_PERMISSIONS).toBe(0n); + expect(await roleBits(owner.token, guild.id, role.id)).toBe( + Permissions.VIEW_CHANNEL | ThreadPermissionFlags.MANAGE_THREADS, + ); + const masked = await listRoles(owner.token, guild.id, false); + expect(masked.every((r) => (bits(r.permissions) & THREAD_PERMISSIONS) === 0n)).toBe(true); + const cleared = await createBuilder(harness, owner.token) + .patch(`/guilds/${guild.id}/roles/${role.id}`) + .header(FEATURES, CAPABLE) + .body({permissions: Permissions.VIEW_CHANNEL.toString()}) + .execute(); + expect(bits(cleared.permissions)).toBe(Permissions.VIEW_CHANNEL); + }); + + test('bots write thread bits and bot invites keep them only for capable active users in active guilds', async () => { + await setConfig(ACTIVE); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Bots'); + const bot = await createTestBotAccount(harness); + const requested = + Permissions.MANAGE_ROLES | Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS; + await createBuilder<{redirect_to: string}>(harness, owner.token) + .post('/oauth2/authorize/consent') + .header(FEATURES, CAPABLE) + .body({client_id: bot.appId, scope: 'bot', guild_id: guild.id, permissions: requested.toString()}) + .execute(); + const botRole = (await listRoles(owner.token, guild.id, true)).find((role) => role.name !== '@everyone'); + expect(bits(botRole?.permissions)).toBe(requested); + const legacyBot = await createTestBotAccount(harness); + await authorizeBot(harness, owner.token, legacyBot.appId, ['bot'], guild.id, requested.toString()); + const legacyRole = (await listRoles(owner.token, guild.id, true)).find( + (role) => role.name !== '@everyone' && role.id !== botRole?.id, + ); + expect(bits(legacyRole?.permissions)).toBe(Permissions.MANAGE_ROLES | Permissions.SEND_MESSAGES); + const written = await createBuilder(harness, `Bot ${bot.botToken}`) + .post(`/guilds/${guild.id}/roles`) + .body({name: 'bot-made', permissions: ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS.toString()}) + .execute(); + expect(bits(written.permissions)).toBe(ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS); + const vcm = await createBuilder(harness, `Bot ${bot.botToken}`) + .post(`/guilds/${guild.id}/roles`) + .body({name: 'bot-vcm', permissions: Permissions.VIEW_CHANNEL_MEMBERS.toString()}) + .execute(); + expect(bits(vcm.permissions)).toBe(0n); + + await setConfig({enabled: false}); + const controlOwner = await createTestAccount(harness); + const controlGuild = await createGuild(harness, controlOwner.token, 'Bots control'); + const controlBot = await createTestBotAccount(harness); + await authorizeBot(harness, controlOwner.token, controlBot.appId, ['bot'], controlGuild.id, requested.toString()); + const controlRole = (await listRoles(controlOwner.token, controlGuild.id, true)).find( + (role) => role.name !== '@everyone', + ); + expect(bits(controlRole?.permissions)).toBe(Permissions.MANAGE_ROLES | Permissions.SEND_MESSAGES); + }); + + test('retired guilds keep stored thread bits read-only without escalation errors', async () => { + await setConfig(ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Retired'); + const target = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/roles`) + .header(FEATURES, CAPABLE) + .body({ + name: 'target', + permissions: (Permissions.SEND_MESSAGES | ThreadPermissionFlags.MANAGE_THREADS).toString(), + }) + .execute(); + const mod = await createRole(harness, owner.token, guild.id, { + name: 'mod', + permissions: (Permissions.MANAGE_ROLES | Permissions.SEND_MESSAGES | Permissions.ADD_REACTIONS).toString(), + }); + await updateRolePositions(harness, owner.token, guild.id, [ + {id: mod.id, position: 2}, + {id: target.id, position: 1}, + ]); + const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!); + const invite = await createChannelInvite(harness, owner.token, systemChannel.id); + await acceptInvite(harness, member.token, invite.code); + await addMemberRole(harness, owner.token, guild.id, member.userId, mod.id); + + await setConfig({enabled: false}); + const updated = await createBuilder(harness, member.token) + .patch(`/guilds/${guild.id}/roles/${target.id}`) + .header(FEATURES, CAPABLE) + .body({ + permissions: ( + Permissions.SEND_MESSAGES | + Permissions.ADD_REACTIONS | + ThreadPermissionFlags.CREATE_PUBLIC_THREADS + ).toString(), + }) + .execute(); + expect(bits(updated.permissions) & ~THREAD_PERMISSIONS).toBe(Permissions.SEND_MESSAGES | Permissions.ADD_REACTIONS); + expect(await roleBits(owner.token, guild.id, target.id)).toBe( + Permissions.SEND_MESSAGES | Permissions.ADD_REACTIONS, + ); + const stored = await new GuildRoleRepository().getRole( + createRoleID(BigInt(target.id)), + createGuildID(BigInt(guild.id)), + ); + expect(stored?.permissions).toBe( + Permissions.SEND_MESSAGES | Permissions.ADD_REACTIONS | ThreadPermissionFlags.MANAGE_THREADS, + ); + + const channel = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .header(FEATURES, CAPABLE) + .body({ + name: 'retired-overwrites', + type: ChannelTypes.GUILD_TEXT, + permission_overwrites: [ + { + id: guild.id, + type: 0, + allow: (Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS).toString(), + deny: '0', + }, + ], + }) + .execute(); + expect(channel.permission_overwrites?.map((overwrite) => bits(overwrite.allow))).toEqual([ + Permissions.SEND_MESSAGES, + ]); + }); + + test('active channel creates store thread-aware overwrites and mask them for non-viewers', async () => { + await setConfig(ACTIVE); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Overwrites'); + const allow = Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS; + const channel = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .header(FEATURES, CAPABLE) + .body({ + name: 'active-overwrites', + type: ChannelTypes.GUILD_TEXT, + permission_overwrites: [{id: guild.id, type: 0, allow: allow.toString(), deny: '0'}], + }) + .execute(); + expect(channel.permission_overwrites?.map((overwrite) => bits(overwrite.allow))).toEqual([allow]); + const viewerList = await createBuilder>(harness, owner.token) + .get(`/guilds/${guild.id}/channels`) + .header(FEATURES, CAPABLE) + .execute(); + const legacyList = await createBuilder>(harness, owner.token) + .get(`/guilds/${guild.id}/channels`) + .execute(); + const pick = (list: Array) => + bits(list.find((entry) => entry.id === channel.id)?.permission_overwrites?.[0]?.allow); + expect(pick(viewerList)).toBe(allow); + expect(pick(legacyList)).toBe(Permissions.SEND_MESSAGES); + const single = async (capable: boolean) => { + const builder = createBuilder(harness, owner.token).get(`/channels/${channel.id}`); + return bits( + (await (capable ? builder.header(FEATURES, CAPABLE) : builder).execute()).permission_overwrites?.[0]?.allow, + ); + }; + expect(await single(true)).toBe(allow); + expect(await single(false)).toBe(Permissions.SEND_MESSAGES); + const patched = await createBuilder(harness, owner.token) + .patch(`/channels/${channel.id}`) + .body({topic: 'masked'}) + .execute(); + expect(bits(patched.permission_overwrites?.[0]?.allow)).toBe(Permissions.SEND_MESSAGES); + }); + + test('overwrite removals keep thread bits the actor cannot write, without escalation errors', async () => { + await setConfig(ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Removals'); + const target = await createRole(harness, owner.token, guild.id, {name: 'target', permissions: '0'}); + const mod = await createRole(harness, owner.token, guild.id, { + name: 'mod', + permissions: (Permissions.MANAGE_ROLES | Permissions.MANAGE_CHANNELS).toString(), + }); + const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!); + const invite = await createChannelInvite(harness, owner.token, systemChannel.id); + await acceptInvite(harness, member.token, invite.code); + await addMemberRole(harness, owner.token, guild.id, member.userId, mod.id); + const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS; + const manageThreads = ThreadPermissionFlags.MANAGE_THREADS; + const channel = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .header(FEATURES, CAPABLE) + .body({ + name: 'removals', + type: ChannelTypes.GUILD_TEXT, + permission_overwrites: [ + {id: guild.id, type: 0, allow: (Permissions.SEND_MESSAGES | inThreads).toString(), deny: '0'}, + {id: target.id, type: 0, allow: '0', deny: (Permissions.ADD_REACTIONS | manageThreads).toString()}, + ], + }) + .execute(); + const channelId = createChannelID(BigInt(channel.id)); + const stored = async () => { + const overwrites = (await new ChannelRepository().findUnique(channelId))!.permissionOverwrites; + return { + everyone: overwrites.get(createRoleID(BigInt(guild.id))), + target: overwrites.get(createRoleID(BigInt(target.id))), + }; + }; + await createBuilder(harness, owner.token) + .patch(`/channels/${channel.id}`) + .body({permission_overwrites: []}) + .execute(); + let after = await stored(); + expect([after.everyone?.allow, after.everyone?.deny]).toEqual([inThreads, 0n]); + expect([after.target?.allow, after.target?.deny]).toEqual([0n, manageThreads]); + + await setConfig({enabled: false}); + await createBuilder(harness, member.token) + .patch(`/channels/${channel.id}`) + .header(FEATURES, CAPABLE) + .body({permission_overwrites: []}) + .execute(); + after = await stored(); + expect([after.everyone?.allow, after.target?.deny]).toEqual([inThreads, manageThreads]); + await createBuilder(harness, member.token) + .delete(`/channels/${channel.id}/permissions/${target.id}`) + .expect(204) + .execute(); + expect((await stored()).target?.deny).toBe(manageThreads); + }); + + test('deleting a forum removes its thread-only index row', async () => { + await setConfig(ACTIVE); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Forum delete'); + const guildId = createGuildID(BigInt(guild.id)); + const text = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'seed', type: ChannelTypes.GUILD_TEXT}) + .execute(); + const repository = new ChannelRepository(); + const before = await repository.channelData.countGuildChannels(guildId); + const source = (await repository.findUnique(createChannelID(BigInt(text.id))))!; + const forumId = createChannelID(BigInt(text.id) + 1n); + await repository.upsert({...source.toRow(), channel_id: forumId, type: ChannelTypes.GUILD_FORUM, name: 'forum'}); + expect(await repository.channelData.countGuildChannels(guildId)).toBe(before + 1); + await createBuilder(harness, owner.token) + .delete(`/channels/${forumId}`) + .header(FEATURES, CAPABLE) + .expect(204) + .execute(); + expect(await repository.channelData.countGuildChannels(guildId)).toBe(before); + }); + test('category sync and delete in a retired guild update forums without dispatching them', async () => { + await setConfig(ACTIVE); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Retired category'); + const guildId = createGuildID(BigInt(guild.id)); + const category = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'cat', type: ChannelTypes.GUILD_CATEGORY}) + .execute(); + const text = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'text', type: ChannelTypes.GUILD_TEXT, parent_id: category.id}) + .execute(); + const repository = new ChannelRepository(); + const source = (await repository.findUnique(createChannelID(BigInt(text.id))))!; + const forumId = createChannelID(BigInt(text.id) + 1n); + await repository.upsert({...source.toRow(), channel_id: forumId, type: ChannelTypes.GUILD_FORUM, name: 'forum'}); + expect(await fetchOne(FETCH_MARKER, {guild_id: guildId})).not.toBeNull(); + await setConfig({enabled: false}); + const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild'); + const updatedIds = () => + dispatchSpy.mock.calls + .filter(([params]) => params.event === 'CHANNEL_UPDATE') + .map(([params]) => (params.data as ChannelResponse).id); + try { + await createBuilder(harness, owner.token) + .patch(`/channels/${category.id}`) + .body({permission_overwrites: [{id: guild.id, type: 0, allow: '0', deny: Permissions.VIEW_CHANNEL.toString()}]}) + .execute(); + expect((await repository.findUnique(forumId))?.permissionOverwrites.size).toBe(1); + expect(updatedIds()).toContain(text.id); + expect(updatedIds()).not.toContain(forumId.toString()); + dispatchSpy.mockClear(); + await createBuilder(harness, owner.token).delete(`/channels/${category.id}`).expect(204).execute(); + expect((await repository.findUnique(forumId))?.parentId).toBeNull(); + expect(updatedIds()).toContain(text.id); + expect(updatedIds()).not.toContain(forumId.toString()); + } finally { + dispatchSpy.mockRestore(); + } + }); + test('channel overwrite edits keep thread bits for clients that cannot write them', async () => { + await setConfig(ACTIVE); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Overwrite edits'); + const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS; + const channel = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .header(FEATURES, CAPABLE) + .body({ + name: 'edits', + type: ChannelTypes.GUILD_TEXT, + permission_overwrites: [ + {id: guild.id, type: 0, allow: (Permissions.SEND_MESSAGES | inThreads).toString(), deny: '0'}, + ], + }) + .execute(); + const channelId = createChannelID(BigInt(channel.id)); + const everyoneId = createRoleID(BigInt(guild.id)); + const storedAllow = async () => + (await new ChannelRepository().findUnique(channelId))?.permissionOverwrites.get(everyoneId)?.allow; + await createBuilder(harness, owner.token) + .patch(`/channels/${channel.id}`) + .body({permission_overwrites: [{id: guild.id, type: 0, allow: Permissions.VIEW_CHANNEL.toString(), deny: '0'}]}) + .execute(); + expect(await storedAllow()).toBe(Permissions.VIEW_CHANNEL | inThreads); + await createBuilder(harness, owner.token) + .put(`/channels/${channel.id}/permissions/${guild.id}`) + .body({type: 0, allow: Permissions.SEND_MESSAGES.toString(), deny: '0'}) + .expect(204) + .execute(); + expect(await storedAllow()).toBe(Permissions.SEND_MESSAGES | inThreads); + await createBuilder(harness, owner.token) + .delete(`/channels/${channel.id}/permissions/${guild.id}`) + .expect(204) + .execute(); + expect(await storedAllow()).toBe(inThreads); + await createBuilder(harness, owner.token) + .delete(`/channels/${channel.id}/permissions/${guild.id}`) + .header(FEATURES, CAPABLE) + .expect(204) + .execute(); + expect(await storedAllow()).toBeUndefined(); + }); + test('lock_permissions moves copy parent thread bits only for writers', async () => { + await setConfig(ACTIVE); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Lock sync'); + const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS; + const category = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .header(FEATURES, CAPABLE) + .body({ + name: 'locked', + type: ChannelTypes.GUILD_CATEGORY, + permission_overwrites: [ + {id: guild.id, type: 0, allow: (Permissions.SEND_MESSAGES | inThreads).toString(), deny: '0'}, + ], + }) + .execute(); + const everyoneId = createRoleID(BigInt(guild.id)); + const moveAndRead = async (capable: boolean) => { + const child = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: capable ? 'capable' : 'legacy', type: ChannelTypes.GUILD_TEXT}) + .execute(); + const builder = createBuilder(harness, owner.token) + .patch(`/guilds/${guild.id}/channels`) + .body([{id: child.id, parent_id: category.id, lock_permissions: true}]) + .expect(204); + await (capable ? builder.header(FEATURES, CAPABLE) : builder).execute(); + const stored = await new ChannelRepository().findUnique(createChannelID(BigInt(child.id))); + return stored?.permissionOverwrites.get(everyoneId)?.allow; + }; + expect(await moveAndRead(true)).toBe(Permissions.SEND_MESSAGES | inThreads); + expect(await moveAndRead(false)).toBe(Permissions.SEND_MESSAGES); + }); + test('template imports keep thread bits only for guilds created while active', async () => { + const templated = async ( + token: string, + everyonePermissions: bigint = Permissions.VIEW_CHANNEL | ThreadPermissionFlags.CREATE_PUBLIC_THREADS, + ) => + createBuilder(harness, token) + .post('/guilds') + .body({ + name: 'Templated', + template: { + name: 'Source', + description: null, + verification_level: 0, + default_message_notifications: 0, + explicit_content_filter: 0, + system_channel_id: 1, + afk_timeout: 300, + system_channel_flags: 0, + roles: [ + { + id: 0, + name: '@everyone', + permissions: everyonePermissions.toString(), + }, + { + id: 2, + name: 'mods', + permissions: (Permissions.KICK_MEMBERS | ThreadPermissionFlags.MANAGE_THREADS).toString(), + }, + ], + channels: [{id: 1, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0}], + }, + }) + .execute(); + const controlOwner = await createTestAccount(harness); + const controlGuild = await templated(controlOwner.token); + const controlRoles = await listRoles(controlOwner.token, controlGuild.id, true); + expect(controlRoles.map((role) => bits(role.permissions) & THREAD_PERMISSIONS)).toEqual([0n, 0n]); + + await setConfig(ACTIVE); + const owner = await createTestAccount(harness); + const guild = await templated(owner.token); + const roles = await listRoles(owner.token, guild.id, true); + expect(bits(roles.find((role) => role.id === guild.id)?.permissions)).toBe( + Permissions.VIEW_CHANNEL | ThreadPermissionFlags.CREATE_PUBLIC_THREADS, + ); + expect(bits(roles.find((role) => role.name === 'mods')?.permissions)).toBe( + Permissions.KICK_MEMBERS | ThreadPermissionFlags.MANAGE_THREADS, + ); + const everyoneOf = async (everyonePermissions: bigint) => { + const created = await templated(owner.token, everyonePermissions); + const createdRoles = await listRoles(owner.token, created.id, true); + return bits(createdRoles.find((role) => role.id === created.id)?.permissions); + }; + expect(await everyoneOf(Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES)).toBe( + Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS, + ); + expect(await everyoneOf(Permissions.VIEW_CHANNEL)).toBe(Permissions.VIEW_CHANNEL); + }); +}); diff --git a/fluxer_api/src/api/guild/tests/UserGuildsThreadsActive.test.ts b/fluxer_api/src/api/guild/tests/UserGuildsThreadsActive.test.ts new file mode 100644 index 000000000..db20b6e0d --- /dev/null +++ b/fluxer_api/src/api/guild/tests/UserGuildsThreadsActive.test.ts @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import type {PreparedQuery} from '@app/api/database/CassandraTypes'; +import { + clearChannelThreadsTaintCacheForTesting, + syncChannelThreadsConfig, +} from '@app/api/experiment/ChannelThreadsGate'; +import {acceptInvite, createChannelInvite, createGuild} from '@app/api/guild/tests/GuildTestUtils'; +import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import { + type ChannelThreadsConfig, + ChannelThreadsConfigSchema, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest'; + +const FEATURES = 'X-Fluxer-Features'; +const CAPABLE = 'channel_threads'; + +async function setConfig(patch: Partial): Promise { + await getInstanceConfigRepository().updateChannelThreadsConfig((current) => + ChannelThreadsConfigSchema.parse({ + ...patch, + ever_enabled: current.ever_enabled || patch.enabled === true, + config_version: current.config_version + 1, + }), + ); + clearChannelThreadsTaintCacheForTesting(); +} + +function resetGate(): void { + syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + clearChannelThreadsTaintCacheForTesting(); +} + +describe('threads_active on GET /users/@me/guilds', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetGate(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetGate(); + }); + + afterAll(async () => { + await harness?.shutdown(); + }); + + async function listGuilds(token: string, capable: boolean): Promise> { + const builder = createBuilder>(harness, token).get('/users/@me/guilds'); + return (capable ? builder.header(FEATURES, CAPABLE) : builder).execute(); + } + + async function setup(): Promise<{ + owner: TestAccount; + member: TestAccount; + enrolled: GuildResponse; + plain: GuildResponse; + ownerBaseline: Array; + memberBaseline: Array; + }> { + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const enrolled = await createGuild(harness, owner.token, 'Enrolled'); + const plain = await createGuild(harness, owner.token, 'Plain'); + const invite = await createChannelInvite(harness, owner.token, enrolled.system_channel_id!); + await acceptInvite(harness, member.token, invite.code); + return { + owner, + member, + enrolled, + plain, + ownerBaseline: await listGuilds(owner.token, true), + memberBaseline: await listGuilds(member.token, true), + }; + } + + function enrol(enrolled: GuildResponse, userIds: Array): Partial { + return {enabled: true, enabled_guild_ids: [enrolled.id], included_user_ids: userIds}; + } + + test('flags only the enrolled guild for an enrolled capable viewer', async () => { + const {owner, enrolled, plain, ownerBaseline} = await setup(); + await setConfig(enrol(enrolled, [owner.userId])); + const guilds = await listGuilds(owner.token, true); + const byId = new Map(guilds.map((guild) => [guild.id, guild])); + const baseline = new Map(ownerBaseline.map((guild) => [guild.id, guild])); + expect(byId.get(enrolled.id)).toEqual({...baseline.get(enrolled.id), threads_active: true}); + expect(byId.get(plain.id)).toEqual(baseline.get(plain.id)); + expect(byId.get(plain.id)).not.toHaveProperty('threads_active'); + }); + + test('control users, plain guilds, incapable clients, excluded users and the kill switch get no key', async () => { + const {owner, member, enrolled, ownerBaseline, memberBaseline} = await setup(); + const expectUnchanged = (guilds: Array, baseline: Array) => { + expect(guilds).toEqual(baseline); + for (const guild of guilds) expect(guild).not.toHaveProperty('threads_active'); + }; + + await setConfig(enrol(enrolled, [owner.userId])); + expectUnchanged(await listGuilds(member.token, true), memberBaseline); + expectUnchanged(await listGuilds(owner.token, false), ownerBaseline); + + await setConfig({enabled: true, enabled_guild_ids: [], included_user_ids: [owner.userId]}); + expectUnchanged(await listGuilds(owner.token, true), ownerBaseline); + + await setConfig({...enrol(enrolled, [owner.userId]), excluded_user_ids: [owner.userId]}); + expectUnchanged(await listGuilds(owner.token, true), ownerBaseline); + + await setConfig({...enrol(enrolled, [owner.userId]), enabled: false}); + expectUnchanged(await listGuilds(owner.token, true), ownerBaseline); + }); + + test('reads no thread marker while the experiment has never been enabled', async () => { + const {owner, ownerBaseline} = await setup(); + const markerReads: Array = []; + let queries = 0; + const original = InMemoryCassandraQueryExecutor.prototype.executeQuery; + vi.spyOn(InMemoryCassandraQueryExecutor.prototype, 'executeQuery').mockImplementation(async function ( + this: InMemoryCassandraQueryExecutor, + query: PreparedQuery, + ) { + queries++; + if (query.cql.includes('guild_thread_state')) markerReads.push(query.cql); + return original.call(this, query); + } as typeof original); + const guilds = await listGuilds(owner.token, true); + expect(guilds).toEqual(ownerBaseline); + expect(queries).toBeGreaterThan(0); + expect(markerReads).toEqual([]); + }); +}); diff --git a/fluxer_api/src/api/infrastructure/ElasticsearchSearchProvider.ts b/fluxer_api/src/api/infrastructure/ElasticsearchSearchProvider.ts index 7c0bc12e4..083330686 100644 --- a/fluxer_api/src/api/infrastructure/ElasticsearchSearchProvider.ts +++ b/fluxer_api/src/api/infrastructure/ElasticsearchSearchProvider.ts @@ -13,8 +13,11 @@ import type {IGuildSearchService} from '@app/api/search/IGuildSearchService'; import type {IMessageSearchService} from '@app/api/search/IMessageSearchService'; import type {IReportSearchService} from '@app/api/search/IReportSearchService'; import type {ISearchProvider} from '@app/api/search/ISearchProvider'; +import type {IThreadSearchService} from '@app/api/search/IThreadSearchService'; import type {IUserSearchService} from '@app/api/search/IUserSearchService'; +import {ThreadSearchIndex} from '@app/api/search/thread/ThreadSearchIndex'; import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter'; +import {ElasticsearchThreadAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchThreadAdapter'; import { createElasticsearchClient, type ElasticsearchClientConfig, @@ -36,6 +39,7 @@ export class ElasticsearchSearchProvider implements ISearchProvider { private reportService: ElasticsearchReportSearchService | null = null; private auditLogService: ElasticsearchAuditLogSearchService | null = null; private guildMemberService: ElasticsearchGuildMemberSearchService | null = null; + private threadService: ThreadSearchIndex | null = null; constructor(options: ElasticsearchSearchProviderOptions) { this.logger = options.logger; @@ -57,6 +61,7 @@ export class ElasticsearchSearchProvider implements ISearchProvider { this.reportService = new ElasticsearchReportSearchService({client, lock}); this.auditLogService = new ElasticsearchAuditLogSearchService({client, lock}); this.guildMemberService = new ElasticsearchGuildMemberSearchService({client, lock}); + this.threadService = new ThreadSearchIndex(new ElasticsearchThreadAdapter({client, lock})); await Promise.all([ this.messageService.initialize(), this.guildService.initialize(), @@ -76,6 +81,7 @@ export class ElasticsearchSearchProvider implements ISearchProvider { this.reportService, this.auditLogService, this.guildMemberService, + this.threadService, ]; await Promise.all(services.filter((s) => s != null).map((s) => s.shutdown())); this.messageService = null; @@ -84,6 +90,7 @@ export class ElasticsearchSearchProvider implements ISearchProvider { this.reportService = null; this.auditLogService = null; this.guildMemberService = null; + this.threadService = null; } getMessageSearchService(): IMessageSearchService | null { @@ -109,4 +116,8 @@ export class ElasticsearchSearchProvider implements ISearchProvider { getGuildMemberSearchService(): IGuildMemberSearchService | null { return this.guildMemberService; } + + getThreadSearchService(): IThreadSearchService | null { + return this.threadService; + } } diff --git a/fluxer_api/src/api/infrastructure/GatewayService.ts b/fluxer_api/src/api/infrastructure/GatewayService.ts index fb2951425..19453a25c 100644 --- a/fluxer_api/src/api/infrastructure/GatewayService.ts +++ b/fluxer_api/src/api/infrastructure/GatewayService.ts @@ -60,6 +60,11 @@ interface DispatchGuildParams { data: unknown; } +interface DispatchGuildManyParams { + guildId: GuildID; + events: Array<{event: GatewayDispatchEvent; data: unknown}>; +} + interface DispatchPresenceParams { userId: UserID; event: GatewayDispatchEvent; @@ -681,6 +686,14 @@ export class GatewayService { }); } + async dispatchGuildMany({guildId, events}: DispatchGuildManyParams): Promise { + if (events.length === 0) return; + await this.call('guild.dispatch_many', { + guild_id: guildId.toString(), + events, + }); + } + async dispatchPresence({userId, event, data}: DispatchPresenceParams): Promise { if (userId === SYSTEM_USER_ID) { return; diff --git a/fluxer_api/src/api/infrastructure/IGatewayService.ts b/fluxer_api/src/api/infrastructure/IGatewayService.ts index 8aabf83ed..d19901604 100644 --- a/fluxer_api/src/api/infrastructure/IGatewayService.ts +++ b/fluxer_api/src/api/infrastructure/IGatewayService.ts @@ -147,6 +147,11 @@ export interface GatewayMentionSourcesPage { export abstract class IGatewayService { abstract dispatchGuild(params: {guildId: GuildID; event: GatewayDispatchEvent; data: unknown}): Promise; + abstract dispatchGuildMany(params: { + guildId: GuildID; + events: Array<{event: GatewayDispatchEvent; data: unknown}>; + }): Promise; + abstract getGuildCounts(guildId: GuildID): Promise<{ memberCount: number; presenceCount: number; diff --git a/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.test.ts b/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.test.ts new file mode 100644 index 000000000..b946e24b2 --- /dev/null +++ b/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.test.ts @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type ChannelID, + channelIdToMessageId, + createChannelID, + createGuildID, + createUserID, +} from '@app/api/BrandedTypes'; +import {ChannelRepository} from '@app/api/channel/repositories/ChannelRepository'; +import {setCassandraQueryExecutorForTesting, upsertOne} from '@app/api/database/CassandraQueryExecution'; +import type {ChannelRow} from '@app/api/database/types/ChannelTypes'; +import { + KVThreadAutoArchiveQueueService, + THREAD_ARCHIVE_GUILDS_KEY, + threadArchiveQueueKey, + threadAutoArchiveDueAt, +} from '@app/api/infrastructure/KVThreadAutoArchiveQueueService'; +import {Channels} from '@app/api/Tables'; +import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; +import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ChannelFlags} from '@fluxer/constants/src/ThreadConstants'; +import {createSnowflakeFromTimestamp} from '@fluxer/snowflake/src/Snowflake'; +import {beforeEach, describe, expect, it} from 'vitest'; + +const GUILD_ID = createGuildID(1_910_000_000_000_000_000n); +const PARENT_ID = createChannelID(1_910_000_000_000_000_100n); +const OWNER_ID = createUserID(1_910_000_000_000_001_000n); +const HOUR = 3_600_000; + +function threadRow(threadId: ChannelID): ChannelRow { + return { + channel_id: threadId, + guild_id: GUILD_ID, + type: ChannelTypes.PUBLIC_THREAD, + name: 'thread', + topic: null, + icon_hash: null, + url: null, + parent_id: PARENT_ID, + position: 0, + owner_id: OWNER_ID, + recipient_ids: null, + nsfw: null, + content_warning_level: null, + content_warning_text: null, + rate_limit_per_user: 0, + bitrate: null, + user_limit: null, + voice_connection_limit: null, + rtc_region: null, + last_message_id: null, + last_pin_timestamp: null, + permission_overwrites: null, + nicks: null, + soft_deleted: false, + indexed_at: null, + version: 1, + }; +} + +describe('KVThreadAutoArchiveQueueService', () => { + let kv: MockKVProvider; + let repositories: ChannelRepository; + let queue: KVThreadAutoArchiveQueueService; + let base: number; + + beforeEach(async () => { + setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor()); + kv = new MockKVProvider(); + repositories = new ChannelRepository(); + queue = new KVThreadAutoArchiveQueueService(kv, repositories.threads, repositories.channelData); + base = Date.now() - 10 * HOUR; + await upsertOne(Channels.upsertAll({...threadRow(PARENT_ID), type: ChannelTypes.GUILD_TEXT, parent_id: null})); + }); + + async function createThread(offsetMs: number, duration: number, flags = 0) { + const threadId = createChannelID(createSnowflakeFromTimestamp(base + offsetMs)); + return repositories.threads.create({ + channel: threadRow(threadId), + parentType: ChannelTypes.GUILD_TEXT, + autoArchiveDuration: duration, + invitable: null, + flags, + appliedTags: [], + hasStarter: false, + createdAt: new Date(base + offsetMs), + members: [{userId: OWNER_ID, flags: 1}], + }); + } + + it('scores by the later of the last message and the archive timestamp plus the duration', async () => { + const state = await createThread(0, 60); + expect(threadAutoArchiveDueAt(state, null)).toBe(base + HOUR); + const later = channelIdToMessageId(createChannelID(createSnowflakeFromTimestamp(base + 2 * HOUR))); + expect(threadAutoArchiveDueAt(state, later)).toBe(base + 3 * HOUR); + }); + + it('returns due threads per guild and tracks the guild index', async () => { + const due = await createThread(0, 60); + const notDue = await createThread(1000, 10080); + await queue.schedule(due, null); + await queue.schedule(notDue, null); + expect(await queue.listGuilds()).toEqual([GUILD_ID]); + expect(await queue.getDue(GUILD_ID, Date.now(), 200)).toEqual([due.threadId]); + await queue.remove(GUILD_ID, due.threadId); + expect(await queue.getDue(GUILD_ID, Date.now(), 200)).toEqual([]); + await queue.remove(GUILD_ID, notDue.threadId); + expect(await kv.smembers(THREAD_ARCHIVE_GUILDS_KEY)).toEqual([]); + }); + + it('keeps the guild indexed when a schedule lands between the empty check and the index removal', async () => { + const removed = await createThread(0, 60); + const added = await createThread(1000, 60); + await queue.schedule(removed, null); + const srem = kv.srem.bind(kv); + kv.srem = async (key, ...members) => { + kv.srem = srem; + await queue.schedule(added, null); + return srem(key, ...members); + }; + await queue.remove(GUILD_ID, removed.threadId); + expect(await queue.listGuilds()).toEqual([GUILD_ID]); + expect(await queue.getDue(GUILD_ID, Date.now(), 200)).toEqual([added.threadId]); + }); + + it('never schedules pinned or archived threads', async () => { + const pinned = await createThread(0, 60, ChannelFlags.PINNED); + await queue.schedule(pinned, null); + expect(await kv.zcard(threadArchiveQueueKey(GUILD_ID))).toBe(0); + const archived = await createThread(10, 60); + const transition = await repositories.threads.updateState(archived.threadId, () => ({archived: true})); + await queue.schedule(transition!.state, null); + expect(await kv.zcard(threadArchiveQueueKey(GUILD_ID))).toBe(0); + }); + + it('rebuilds a guild from its active partition', async () => { + const first = await createThread(0, 60); + const second = await createThread(1000, 60); + await createThread(2000, 60, ChannelFlags.PINNED); + await kv.zadd(threadArchiveQueueKey(GUILD_ID), 1, '123'); + expect(await queue.rebuildGuild(GUILD_ID)).toBe(2); + expect(await queue.getDue(GUILD_ID, Date.now(), 200)).toEqual([first.threadId, second.threadId]); + expect(await queue.getDue(GUILD_ID, Date.now(), 1)).toEqual([first.threadId]); + }); +}); diff --git a/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.ts b/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.ts new file mode 100644 index 000000000..09988cd83 --- /dev/null +++ b/fluxer_api/src/api/infrastructure/KVThreadAutoArchiveQueueService.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type ChannelID, createChannelID, createGuildID, type GuildID, type MessageID} from '@app/api/BrandedTypes'; +import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository'; +import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; +import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider'; + +export const THREAD_ARCHIVE_GUILDS_KEY = 'thread_archive_guilds'; + +export function threadArchiveQueueKey(guildId: GuildID): string { + return `thread_archive:${guildId}`; +} + +export function threadAutoArchiveDueAt(state: ThreadState, lastMessageId: MessageID | null): number { + const lastActivity = lastMessageId ? snowflakeToDate(lastMessageId).getTime() : 0; + const archivedAt = state.archiveTimestamp?.getTime() ?? state.createdAt.getTime(); + return Math.max(lastActivity, archivedAt) + state.autoArchiveDuration * 60_000; +} + +export class KVThreadAutoArchiveQueueService { + constructor( + private readonly kvClient: IKVProvider, + private readonly threads: IThreadRepository, + private readonly channelData: IChannelDataRepository, + ) {} + + async schedule(state: ThreadState, lastMessageId: MessageID | null): Promise { + if (state.archived || state.isPinned) { + await this.remove(state.guildId, state.threadId); + return; + } + await this.kvClient.zadd( + threadArchiveQueueKey(state.guildId), + threadAutoArchiveDueAt(state, lastMessageId), + state.threadId.toString(), + ); + await this.kvClient.sadd(THREAD_ARCHIVE_GUILDS_KEY, state.guildId.toString()); + } + + async remove(guildId: GuildID, threadId: ChannelID): Promise { + const key = threadArchiveQueueKey(guildId); + await this.kvClient.zrem(key, threadId.toString()); + if ((await this.kvClient.zcard(key)) !== 0) return; + await this.kvClient.srem(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString()); + if ((await this.kvClient.zcard(key)) !== 0) { + await this.kvClient.sadd(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString()); + } + } + + async removeGuild(guildId: GuildID): Promise { + await this.kvClient.del(threadArchiveQueueKey(guildId)); + await this.kvClient.srem(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString()); + } + + async getDue(guildId: GuildID, nowMs: number, limit: number): Promise> { + const members = await this.kvClient.zrangebyscore(threadArchiveQueueKey(guildId), '-inf', nowMs, 'LIMIT', 0, limit); + return members.flatMap((member) => (/^\d+$/.test(member) ? [createChannelID(BigInt(member))] : [])); + } + + async listGuilds(): Promise> { + const members = await this.kvClient.smembers(THREAD_ARCHIVE_GUILDS_KEY); + return members.flatMap((member) => (/^\d+$/.test(member) ? [createGuildID(BigInt(member))] : [])); + } + + async rebuildGuild(guildId: GuildID): Promise { + const key = threadArchiveQueueKey(guildId); + const states = (await this.threads.listActiveThreads(guildId)).filter((state) => !state.isPinned); + const channels = await this.channelData.listChannels(states.map((state) => state.threadId)); + const lastMessageIds = new Map(channels.map((channel) => [channel.id, channel.lastMessageId])); + await this.kvClient.del(key); + if (states.length === 0) { + await this.kvClient.srem(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString()); + return 0; + } + const scoreMembers = states.flatMap((state) => [ + threadAutoArchiveDueAt(state, lastMessageIds.get(state.threadId) ?? null), + state.threadId.toString(), + ]); + await this.kvClient.zadd(key, ...scoreMembers); + await this.kvClient.sadd(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString()); + return states.length; + } +} diff --git a/fluxer_api/src/api/infrastructure/MeilisearchSearchProvider.ts b/fluxer_api/src/api/infrastructure/MeilisearchSearchProvider.ts index 5ac93f048..84c5b9fb2 100644 --- a/fluxer_api/src/api/infrastructure/MeilisearchSearchProvider.ts +++ b/fluxer_api/src/api/infrastructure/MeilisearchSearchProvider.ts @@ -7,8 +7,10 @@ import type {IGuildSearchService} from '@app/api/search/IGuildSearchService'; import type {IMessageSearchService} from '@app/api/search/IMessageSearchService'; import type {IReportSearchService} from '@app/api/search/IReportSearchService'; import type {ISearchProvider} from '@app/api/search/ISearchProvider'; +import type {IThreadSearchService} from '@app/api/search/IThreadSearchService'; import type {IUserSearchService} from '@app/api/search/IUserSearchService'; import {type MeilisearchClientConfig, MeilisearchHttpClient} from '@app/api/search/meilisearch/MeilisearchClient'; +import {MeilisearchThreadAdapter} from '@app/api/search/meilisearch/MeilisearchDomainAdapters'; import { MeilisearchAuditLogSearchService, MeilisearchGuildMemberSearchService, @@ -17,6 +19,7 @@ import { MeilisearchReportSearchService, MeilisearchUserSearchService, } from '@app/api/search/meilisearch/MeilisearchSearchServices'; +import {ThreadSearchIndex} from '@app/api/search/thread/ThreadSearchIndex'; interface MeilisearchSearchProviderOptions { config: MeilisearchClientConfig; @@ -32,6 +35,7 @@ export class MeilisearchSearchProvider implements ISearchProvider { private reportService: MeilisearchReportSearchService | null = null; private auditLogService: MeilisearchAuditLogSearchService | null = null; private guildMemberService: MeilisearchGuildMemberSearchService | null = null; + private threadService: ThreadSearchIndex | null = null; constructor(options: MeilisearchSearchProviderOptions) { this.config = options.config; @@ -47,6 +51,7 @@ export class MeilisearchSearchProvider implements ISearchProvider { this.reportService = new MeilisearchReportSearchService(client); this.auditLogService = new MeilisearchAuditLogSearchService(client); this.guildMemberService = new MeilisearchGuildMemberSearchService(client); + this.threadService = new ThreadSearchIndex(new MeilisearchThreadAdapter({client})); await Promise.all([ this.messageService.initialize(), this.guildService.initialize(), @@ -66,6 +71,7 @@ export class MeilisearchSearchProvider implements ISearchProvider { this.reportService, this.auditLogService, this.guildMemberService, + this.threadService, ]; await Promise.all(services.filter((service) => service != null).map((service) => service.shutdown())); this.messageService = null; @@ -74,6 +80,7 @@ export class MeilisearchSearchProvider implements ISearchProvider { this.reportService = null; this.auditLogService = null; this.guildMemberService = null; + this.threadService = null; } getMessageSearchService(): IMessageSearchService | null { @@ -99,4 +106,8 @@ export class MeilisearchSearchProvider implements ISearchProvider { getGuildMemberSearchService(): IGuildMemberSearchService | null { return this.guildMemberService; } + + getThreadSearchService(): IThreadSearchService | null { + return this.threadService; + } } diff --git a/fluxer_api/src/api/infrastructure/NullSearchProvider.ts b/fluxer_api/src/api/infrastructure/NullSearchProvider.ts index fd6bffc54..ca5e75ad6 100644 --- a/fluxer_api/src/api/infrastructure/NullSearchProvider.ts +++ b/fluxer_api/src/api/infrastructure/NullSearchProvider.ts @@ -6,6 +6,7 @@ import type {IGuildSearchService} from '@app/api/search/IGuildSearchService'; import type {IMessageSearchService} from '@app/api/search/IMessageSearchService'; import type {IReportSearchService} from '@app/api/search/IReportSearchService'; import type {ISearchProvider} from '@app/api/search/ISearchProvider'; +import type {IThreadSearchService} from '@app/api/search/IThreadSearchService'; import type {IUserSearchService} from '@app/api/search/IUserSearchService'; export class NullSearchProvider implements ISearchProvider { @@ -36,4 +37,8 @@ export class NullSearchProvider implements ISearchProvider { getGuildMemberSearchService(): IGuildMemberSearchService | null { return null; } + + getThreadSearchService(): IThreadSearchService | null { + return null; + } } diff --git a/fluxer_api/src/api/instance/ChannelThreadsConfigPublisher.ts b/fluxer_api/src/api/instance/ChannelThreadsConfigPublisher.ts new file mode 100644 index 000000000..bb1d00ee6 --- /dev/null +++ b/fluxer_api/src/api/instance/ChannelThreadsConfigPublisher.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager'; + +const textEncoder = new TextEncoder(); + +export const CHANNEL_THREADS_CONFIG_NATS_SUBJECT = 'config.channel.threads'; + +interface ChannelThreadsConfigNatsMessage { + type: 'channel_threads_config'; + config: ChannelThreadsConfig; +} + +export class ChannelThreadsConfigPublisher { + constructor(private readonly connectionManager: INatsConnectionManager) {} + + async publish(config: ChannelThreadsConfig): Promise { + if (this.connectionManager.isClosed()) { + await this.connectionManager.connect(); + } + const connection = this.connectionManager.getConnection(); + const message: ChannelThreadsConfigNatsMessage = { + type: 'channel_threads_config', + config, + }; + connection.publish(CHANNEL_THREADS_CONFIG_NATS_SUBJECT, textEncoder.encode(JSON.stringify(message))); + await connection.flush(); + } +} diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.ts index 59516ca0f..c56f8ca7a 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -6,6 +6,7 @@ import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/ap import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution'; import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes'; import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes'; +import {syncChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate'; import { type AccountIdentity, resolveAccountIdentity, @@ -50,6 +51,11 @@ import { CaptchaConfigSchema, type CaptchaConfigUpdateRequest, } from '@fluxer/schema/src/domains/admin/CaptchaSchemas'; +import { + type ChannelThreadsConfig, + ChannelThreadsConfigSchema, + type CompiledChannelThreadsConfig, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; import { type DomainMigrationConfig, DomainMigrationConfigSchema, @@ -96,6 +102,7 @@ const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config'; const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config'; const PLUTONIUM_PAGE_CONFIG_KEY = 'plutonium_page_config'; const CAPTCHA_CONFIG_KEY = 'captcha_config'; +const CHANNEL_THREADS_CONFIG_KEY = 'channel_threads_config'; const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config'; const REGISTRATION_CONFIG_KEY = 'registration_config'; const REGISTRATION_URLS_KEY = 'registration_urls'; @@ -428,6 +435,7 @@ type StoredConfigSection = | 'domain migration' | 'plutonium page' | 'captcha' + | 'channel threads' | 'experiment delivery' | 'instance policy' | 'integrations' @@ -653,6 +661,10 @@ function parseStoredCaptchaConfig(raw: string | null): CaptchaConfig { return parseStoredConfigOrDefault(CaptchaConfigSchema, raw, 'captcha'); } +function parseStoredChannelThreadsConfig(raw: string | null): ChannelThreadsConfig { + return parseStoredConfigOrDefault(ChannelThreadsConfigSchema, raw, 'channel threads'); +} + function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig { return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery'); } @@ -1317,6 +1329,7 @@ export class InstanceConfigRepository { parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null); parseStoredPlutoniumPageConfig(snapshot.get(PLUTONIUM_PAGE_CONFIG_KEY) ?? null); parseStoredCaptchaConfig(snapshot.get(CAPTCHA_CONFIG_KEY) ?? null); + syncChannelThreadsConfig(snapshot.get(CHANNEL_THREADS_CONFIG_KEY) ?? null, parseStoredChannelThreadsConfig); parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null); parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null); checkStoredConfig('registration', () => @@ -1366,6 +1379,7 @@ export class InstanceConfigRepository { clearCacheForTesting(): void { setCachedAccountIdentity(null); + syncChannelThreadsConfig(null, parseStoredChannelThreadsConfig); const shutdown = this.shutdown(); this.configCache = this.createConfigCache(shutdown); void shutdown.catch((error) => { @@ -1585,6 +1599,30 @@ export class InstanceConfigRepository { ); } + async getChannelThreadsConfig(): Promise { + return (await this.getCompiledChannelThreadsConfig()).config; + } + + async getCompiledChannelThreadsConfig(): Promise { + const raw = await this.getConfig(CHANNEL_THREADS_CONFIG_KEY); + return syncChannelThreadsConfig(raw, parseStoredChannelThreadsConfig); + } + + async refreshChannelThreadsConfig(): Promise { + const raw = await this.fetchConfigFromDatabase(CHANNEL_THREADS_CONFIG_KEY); + return syncChannelThreadsConfig(raw, parseStoredChannelThreadsConfig); + } + + async updateChannelThreadsConfig( + update: (current: ChannelThreadsConfig) => ChannelThreadsConfig, + ): Promise { + const landed = await this.updateStoredConfig(CHANNEL_THREADS_CONFIG_KEY, (raw) => + validateStoredConfig(ChannelThreadsConfigSchema, update(parseStoredChannelThreadsConfig(raw)), 'channel threads'), + ); + syncChannelThreadsConfig(JSON.stringify(landed), parseStoredChannelThreadsConfig); + return landed; + } + async getExperimentDeliveryConfig(): Promise { const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY); return parseStoredExperimentDeliveryConfig(raw); diff --git a/fluxer_api/src/api/invite/InviteController.ts b/fluxer_api/src/api/invite/InviteController.ts index ab7a344d6..6130d1888 100644 --- a/fluxer_api/src/api/invite/InviteController.ts +++ b/fluxer_api/src/api/invite/InviteController.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createChannelID, createGuildID, createInviteCode} from '@app/api/BrandedTypes'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; @@ -82,7 +83,7 @@ export function InviteController(app: HonoApp) { const inviteCode = createInviteCode(ctx.req.valid('param').invite_code); const inviteRequestService = ctx.get('inviteRequestService'); const auditLogReason = ctx.get('auditLogReason') ?? null; - await inviteRequestService.deleteInvite({userId, inviteCode, auditLogReason}); + await inviteRequestService.deleteInvite({viewer: viewerFromCtx(ctx), userId, inviteCode, auditLogReason}); return ctx.body(null, 204); }, ); @@ -110,6 +111,7 @@ export function InviteController(app: HonoApp) { const requestCache = ctx.get('requestCache'); return ctx.json( await inviteRequestService.createChannelInvite({ + viewer: viewerFromCtx(ctx), inviterId: userId, channelId, requestCache, @@ -139,7 +141,9 @@ export function InviteController(app: HonoApp) { const channelId = createChannelID(ctx.req.valid('param').channel_id); const inviteRequestService = ctx.get('inviteRequestService'); const requestCache = ctx.get('requestCache'); - return ctx.json(await inviteRequestService.listChannelInvites({userId, channelId, requestCache})); + return ctx.json( + await inviteRequestService.listChannelInvites({viewer: viewerFromCtx(ctx), userId, channelId, requestCache}), + ); }, ); app.get( diff --git a/fluxer_api/src/api/invite/InviteRequestService.ts b/fluxer_api/src/api/invite/InviteRequestService.ts index 33a07c93f..aac044188 100644 --- a/fluxer_api/src/api/invite/InviteRequestService.ts +++ b/fluxer_api/src/api/invite/InviteRequestService.ts @@ -3,6 +3,7 @@ import type {ChannelID, GuildID, InviteCode, UserID} from '@app/api/BrandedTypes'; import {mapChannelToPartialResponse} from '@app/api/channel/ChannelMappers'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildService} from '@app/api/guild/services/GuildService'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; @@ -62,10 +63,15 @@ export class InviteRequestService { return this.mapInviteResponse(invite, params.requestCache); } - async deleteInvite(params: {userId: UserID; inviteCode: InviteCode; auditLogReason?: string | null}): Promise { + async deleteInvite(params: { + userId: UserID; + viewer: ThreadViewer; + inviteCode: InviteCode; + auditLogReason?: string | null; + }): Promise { const invite = await this.inviteService.getInvite(params.inviteCode); await this.inviteService.deleteInvite( - {userId: params.userId, inviteCode: params.inviteCode}, + {userId: params.userId, viewer: params.viewer, inviteCode: params.inviteCode}, params.auditLogReason, ); await this.inviteService.dispatchInviteDelete(invite); @@ -73,6 +79,7 @@ export class InviteRequestService { async createChannelInvite(params: { inviterId: UserID; + viewer: ThreadViewer; channelId: ChannelID; requestCache: RequestCache; data: ChannelInviteCreateRequest; @@ -81,6 +88,7 @@ export class InviteRequestService { const {invite, isNew} = await this.inviteService.createInvite( { inviterId: params.inviterId, + viewer: params.viewer, channelId: params.channelId, maxUses: params.data.max_uses ?? 0, maxAge: params.data.max_age ?? 0, @@ -98,11 +106,13 @@ export class InviteRequestService { async listChannelInvites(params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; requestCache: RequestCache; }): Promise> { const invites = await this.inviteService.getChannelInvitesSorted({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, }); return this.mapInviteList(invites, params.requestCache); diff --git a/fluxer_api/src/api/invite/InviteService.ts b/fluxer_api/src/api/invite/InviteService.ts index 61ec6baed..9dafbc5fb 100644 --- a/fluxer_api/src/api/invite/InviteService.ts +++ b/fluxer_api/src/api/invite/InviteService.ts @@ -4,6 +4,7 @@ import type {ApiContext} from '@app/api/ApiContext'; import type {ChannelID, GuildID, InviteCode, UserID} from '@app/api/BrandedTypes'; import {createInviteCode, vanityCodeToInviteCode} from '@app/api/BrandedTypes'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; +import {THREAD_FEATURE_CHANNEL_TYPES, type ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {GuildService} from '@app/api/guild/services/GuildService'; import type {IInviteRepository} from '@app/api/invite/IInviteRepository'; @@ -20,6 +21,7 @@ import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {ChannelTypes, InviteTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildFeatures, GuildOperations, JoinSourceTypes} from '@fluxer/constants/src/GuildConstants'; import {MAX_GUILD_INVITES} from '@fluxer/constants/src/LimitConstants'; +import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; import {UnclaimedAccountCannotJoinGroupDmsError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotJoinGroupDmsError'; import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; @@ -37,6 +39,7 @@ const INVITE_USE_RESERVATION_EXTRA_ATTEMPTS = 8; interface GetChannelInvitesParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; } @@ -47,6 +50,7 @@ interface GetGuildInvitesParams { interface CreateInviteParams { inviterId: UserID; + viewer: ThreadViewer; channelId: ChannelID; maxUses: number; maxAge: number; @@ -62,11 +66,13 @@ interface AcceptInviteParams { interface DeleteInviteParams { userId: UserID; + viewer: ThreadViewer; inviteCode: InviteCode; } interface GetChannelInvitesSortedParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; } @@ -115,8 +121,8 @@ export class InviteService { throw new UnknownInviteError(); } - async getChannelInvites({userId, channelId}: GetChannelInvitesParams): Promise> { - const channel = await this.channelService.channelData.operations.getChannel({userId, channelId}); + async getChannelInvites({userId, viewer, channelId}: GetChannelInvitesParams): Promise> { + const channel = await this.channelService.channelData.operations.getChannel({userId, viewer, channelId}); if (!channel.guildId) { if (channel.type !== ChannelTypes.GROUP_DM) throw new UnknownChannelError(); if (channel.ownerId !== userId) { @@ -144,7 +150,7 @@ export class InviteService { } async createInvite( - {inviterId, channelId, maxUses, maxAge, unique, temporary = false}: CreateInviteParams, + {inviterId, viewer, channelId, maxUses, maxAge, unique, temporary = false}: CreateInviteParams, auditLogReason?: string | null, ): Promise<{ invite: Invite; @@ -152,8 +158,12 @@ export class InviteService { }> { const channel = await this.channelService.channelData.operations.getChannel({ userId: inviterId, + viewer, channelId, }); + if (THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) { + throw new InvalidChannelTypeError(); + } if (!channel.guildId) { if (!unique) { const channelInvites = await this.inviteRepository.listChannelInvites(channelId); @@ -411,13 +421,14 @@ export class InviteService { return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, 'max_guild_invites', limit); } - async deleteInvite({userId, inviteCode}: DeleteInviteParams, auditLogReason?: string | null): Promise { + async deleteInvite({userId, viewer, inviteCode}: DeleteInviteParams, auditLogReason?: string | null): Promise { const invite = await this.findInviteWithLowercaseFallback(inviteCode); if (!invite) throw new UnknownInviteError(); if (invite.type === InviteTypes.GROUP_DM) { if (!invite.channelId) throw new UnknownInviteError(); const channel = await this.channelService.channelData.operations.getChannel({ userId, + viewer, channelId: invite.channelId, }); if (!channel.recipientIds.has(userId)) { @@ -456,8 +467,8 @@ export class InviteService { return await this.channelService.channelData.operations.getChannelSystem(channelId); } - async getChannelInvitesSorted({userId, channelId}: GetChannelInvitesSortedParams): Promise> { - const invites = await this.getChannelInvites({userId, channelId}); + async getChannelInvitesSorted({userId, viewer, channelId}: GetChannelInvitesSortedParams): Promise> { + const invites = await this.getChannelInvites({userId, viewer, channelId}); return invites.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); } diff --git a/fluxer_api/src/api/middleware/AuthMiddleware.ts b/fluxer_api/src/api/middleware/AuthMiddleware.ts index 1e7d7cdde..32e53d24b 100644 --- a/fluxer_api/src/api/middleware/AuthMiddleware.ts +++ b/fluxer_api/src/api/middleware/AuthMiddleware.ts @@ -32,3 +32,13 @@ export const DefaultUserOnly = createMiddleware(async (ctx, next) => { } await next(); }); +export const BotOnly = createMiddleware(async (ctx, next) => { + const user = ctx.get('user'); + if (!user) { + throw new UnauthorizedError(); + } + if (!user.isBot) { + throw new AccessDeniedError(); + } + await next(); +}); diff --git a/fluxer_api/src/api/middleware/ClientFeaturesMiddleware.ts b/fluxer_api/src/api/middleware/ClientFeaturesMiddleware.ts new file mode 100644 index 000000000..34eed441f --- /dev/null +++ b/fluxer_api/src/api/middleware/ClientFeaturesMiddleware.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {HonoEnv} from '@app/api/types/HonoEnv'; +import {CLIENT_FEATURES_HEADER, parseClientFeaturesHeader} from '@app/api/utils/featureUtils'; +import {createMiddleware} from 'hono/factory'; + +export const ClientFeaturesMiddleware = createMiddleware(async (ctx, next) => { + ctx.set('clientFeatures', parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER))); + return next(); +}); diff --git a/fluxer_api/src/api/middleware/LazyServiceProvider.ts b/fluxer_api/src/api/middleware/LazyServiceProvider.ts index 0a3bdffae..2685b3333 100644 --- a/fluxer_api/src/api/middleware/LazyServiceProvider.ts +++ b/fluxer_api/src/api/middleware/LazyServiceProvider.ts @@ -19,6 +19,7 @@ type ExternallySetVariable = | 'authUserId' | 'authViaCookie' | 'channelUpdateType' + | 'clientFeatures' | 'oauthBearerAllowed' | 'oauthBearerApplicationId' | 'oauthBearerScopes' diff --git a/fluxer_api/src/api/middleware/ResponseTypeMiddleware.ts b/fluxer_api/src/api/middleware/ResponseTypeMiddleware.ts index 340d2b756..a910bef42 100644 --- a/fluxer_api/src/api/middleware/ResponseTypeMiddleware.ts +++ b/fluxer_api/src/api/middleware/ResponseTypeMiddleware.ts @@ -98,6 +98,7 @@ export interface OpenAPIRouteMetadata { summary: string; description: string; responseSchema: ZodType | null; + acceptedResponseSchema?: ZodType; responseContentType?: string; requestSchema?: ZodType; requestFormSchema?: ZodType; @@ -111,6 +112,7 @@ export interface OpenAPIRouteMetadata { url: string; description?: string; }; + experiment?: string; } interface OpenAPIOptions { @@ -181,9 +183,16 @@ export function OpenAPI( ctx.set('openapiMetadata', fullMetadata); ctx.set('responseSchema', schema); await next(); - if (!schema || !hasJsonResponse || !Config.dev.validateResponses || bodylessStatusCodes?.includes(ctx.res.status)) { + const statusSchema = + ctx.res.status === 202 && metadata.acceptedResponseSchema ? metadata.acceptedResponseSchema : schema; + if ( + !statusSchema || + !hasJsonResponse || + !Config.dev.validateResponses || + bodylessStatusCodes?.includes(ctx.res.status) + ) { return; } - await validateAndRewriteResponse(ctx, schema); + await validateAndRewriteResponse(ctx, statusSchema); }; } diff --git a/fluxer_api/src/api/middleware/ServiceMiddleware.ts b/fluxer_api/src/api/middleware/ServiceMiddleware.ts index 492b67760..f846f7ed8 100644 --- a/fluxer_api/src/api/middleware/ServiceMiddleware.ts +++ b/fluxer_api/src/api/middleware/ServiceMiddleware.ts @@ -14,7 +14,9 @@ import {ChannelRequestService} from '@app/api/channel/services/ChannelRequestSer import {CrosspostSourceService} from '@app/api/channel/services/message/CrosspostSourceService'; import {MessageRequestService} from '@app/api/channel/services/message/MessageRequestService'; import {createMessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService'; +import {ThreadMessageResponses} from '@app/api/channel/services/message/ThreadMessageResponses'; import {StreamService} from '@app/api/channel/services/StreamService'; +import {ThreadService} from '@app/api/channel/services/thread/ThreadService'; import {ConnectionRequestService} from '@app/api/connection/ConnectionRequestService'; import {ConnectionService} from '@app/api/connection/ConnectionService'; import {DonationService} from '@app/api/donation/DonationService'; @@ -88,6 +90,7 @@ import { getKVAccountDeletionQueue, getKVActivityTracker, getKVBulkMessageDeletionQueue, + getKVThreadAutoArchiveQueue, getLimitConfigService, getNcmecSubmissionService, getOAuth2TokenRepository, @@ -213,6 +216,7 @@ class RequestServices implements RequestScopedServices { private cachedSsoService: SsoService | undefined; private cachedDesktopHandoffService: DesktopHandoffService | undefined; private cachedChannelRequestService: ChannelRequestService | undefined; + private cachedThreadService: ThreadService | undefined; private cachedMessageRequestService: MessageRequestService | undefined; private cachedConnectionService: ConnectionService | undefined; private cachedConnectionRequestService: ConnectionRequestService | undefined; @@ -583,10 +587,41 @@ class RequestServices implements RequestScopedServices { } get channelRequestService(): ChannelRequestService { - this.cachedChannelRequestService ??= new ChannelRequestService(this.channelService, getUserCacheService()); + this.cachedChannelRequestService ??= new ChannelRequestService( + this.channelService, + getUserCacheService(), + getCacheService(), + ); return this.cachedChannelRequestService; } + get threadService(): ThreadService { + this.cachedThreadService ??= new ThreadService({ + channelRepository: this.channelRepository, + guildRepository: this.requestGuildRepository, + userRepository: getUserRepository(), + channelAuth: this.channelService.channelData.auth, + gatewayService: this.gatewayService, + snowflakeService: getSnowflakeService(), + rateLimitService: getRateLimitService(), + cacheService: getCacheService(), + guildAuditLogService: getGuildAuditLogService(), + userCacheService: getUserCacheService(), + archiveQueue: getKVThreadAutoArchiveQueue(), + messagePersistence: this.channelService.messages.persistence, + threadMessageResponses: new ThreadMessageResponses( + this.channelRepository, + createMessageResponseDataService(), + getUserCacheService(), + ), + purgeChannelAttachments: (channel) => this.channelService.attachments.purgeChannelAttachments(channel), + readStateService: getReadStateService(), + sendForumStarter: (params) => this.messageRequestService.sendForumStarter(params), + validateForumStarter: (params) => this.messageRequestService.validateForumStarter(params), + }); + return this.cachedThreadService; + } + get messageRequestService(): MessageRequestService { this.cachedMessageRequestService ??= new MessageRequestService( this.channelService, @@ -874,6 +909,7 @@ class RequestServices implements RequestScopedServices { getSnowflakeService(), getGuildAuditLogService(), getLimitConfigService(), + () => this.threadService, ); return this.cachedWebhookService; } diff --git a/fluxer_api/src/api/middleware/ServiceSingletons.ts b/fluxer_api/src/api/middleware/ServiceSingletons.ts index 339444bd6..6d7572616 100644 --- a/fluxer_api/src/api/middleware/ServiceSingletons.ts +++ b/fluxer_api/src/api/middleware/ServiceSingletons.ts @@ -40,12 +40,14 @@ import type {IUnfurlerService} from '@app/api/infrastructure/IUnfurlerService'; import {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService'; import {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker'; import {KVBulkMessageDeletionQueueService} from '@app/api/infrastructure/KVBulkMessageDeletionQueueService'; +import {KVThreadAutoArchiveQueueService} from '@app/api/infrastructure/KVThreadAutoArchiveQueueService'; import {NatsUnfurlerService} from '@app/api/infrastructure/NatsUnfurlerService'; import {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService'; import {createStorageService} from '@app/api/infrastructure/StorageServiceFactory'; import {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient'; import {VirusScanService} from '@app/api/infrastructure/VirusScanService'; +import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher'; import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher'; import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository'; import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher'; @@ -173,6 +175,17 @@ export const getPushRelayConfigPublisher = singleton( ), ); +export const getChannelThreadsConfigPublisher = singleton( + () => + new ChannelThreadsConfigPublisher( + new NatsConnectionManager({ + url: Config.nats.coreUrl, + token: Config.nats.authToken || undefined, + name: 'fluxer-api-channel-threads-config', + }), + ), +); + export const getVisionarySlotRepository = singleton(() => new VisionarySlotRepository()); export const getCacheService: () => ICacheService = singleton(() => new KVCacheProvider({client: getKVClient()})); export const getRateLimitService = singleton(() => new RateLimitService(getKVClient())); @@ -259,6 +272,19 @@ export function getKVBulkMessageDeletionQueue(): KVBulkMessageDeletionQueueServi return bulkMessageDeletionQueue; } +let threadAutoArchiveQueueClient: IKVProvider | null = null; +let threadAutoArchiveQueue: KVThreadAutoArchiveQueueService | null = null; + +export function getKVThreadAutoArchiveQueue(): KVThreadAutoArchiveQueueService { + const kvClient = getKVClient(); + if (!threadAutoArchiveQueue || threadAutoArchiveQueueClient !== kvClient) { + const channels = getChannelRepository(); + threadAutoArchiveQueue = new KVThreadAutoArchiveQueueService(kvClient, channels.threads, channels.channelData); + threadAutoArchiveQueueClient = kvClient; + } + return threadAutoArchiveQueue; +} + let premiumStateQueueClient: IKVProvider | null = null; let premiumStateQueue: PremiumStateReconciliationQueueService | null = null; diff --git a/fluxer_api/src/api/middleware/tests/LazyServiceProvider.test.ts b/fluxer_api/src/api/middleware/tests/LazyServiceProvider.test.ts index a8cd36d6f..6c2db7ac2 100644 --- a/fluxer_api/src/api/middleware/tests/LazyServiceProvider.test.ts +++ b/fluxer_api/src/api/middleware/tests/LazyServiceProvider.test.ts @@ -70,6 +70,7 @@ const REQUEST_SERVICE_VARIABLES: ReadonlyArray = [ 'stripeService', 'sweegoWebhookService', 'themeService', + 'threadService', 'userAccountRequestService', 'userActivityBuffer', 'userAuthRequestService', diff --git a/fluxer_api/src/api/models/Channel.ts b/fluxer_api/src/api/models/Channel.ts index 838f55718..257893f13 100644 --- a/fluxer_api/src/api/models/Channel.ts +++ b/fluxer_api/src/api/models/Channel.ts @@ -5,6 +5,11 @@ import type {ChannelRow, PermissionOverwrite} from '@app/api/database/types/Chan import {ChannelPermissionOverwrite} from '@app/api/models/ChannelPermissionOverwrite'; import {type ChannelType, ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT} from '@fluxer/constants/src/LimitConstants'; +import { + THREAD_CHANNEL_TYPES, + THREAD_ONLY_CHANNEL_TYPES, + THREAD_PARENT_CHANNEL_TYPES, +} from '@fluxer/constants/src/ThreadConstants'; export class Channel { readonly id: ChannelID; @@ -72,6 +77,22 @@ export class Channel { this.version = row.version; } + isThread(): boolean { + return THREAD_CHANNEL_TYPES.has(this.type); + } + + isPrivateThread(): boolean { + return this.type === ChannelTypes.PRIVATE_THREAD; + } + + isThreadOnly(): boolean { + return THREAD_ONLY_CHANNEL_TYPES.has(this.type); + } + + isThreadParent(): boolean { + return THREAD_PARENT_CHANNEL_TYPES.has(this.type); + } + toRow(): ChannelRow { const permOverwritesMap: Map | null = this.permissionOverwrites.size > 0 diff --git a/fluxer_api/src/api/models/ForumTag.ts b/fluxer_api/src/api/models/ForumTag.ts new file mode 100644 index 000000000..dffcacacc --- /dev/null +++ b/fluxer_api/src/api/models/ForumTag.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ForumTagUdt} from '@app/api/database/types/ThreadTypes'; + +export class ForumTag { + readonly id: bigint; + readonly name: string; + readonly moderated: boolean; + readonly emojiId: bigint | null; + readonly emojiName: string | null; + + constructor(tag: ForumTagUdt) { + this.id = tag.id; + this.name = tag.name; + this.moderated = tag.moderated ?? false; + this.emojiId = tag.emoji_id ?? null; + this.emojiName = tag.emoji_name ?? null; + } + + toUdt(): ForumTagUdt { + return { + id: this.id, + name: this.name, + moderated: this.moderated, + emoji_id: this.emojiId, + emoji_name: this.emojiName, + }; + } +} diff --git a/fluxer_api/src/api/models/GuildChannelOverride.ts b/fluxer_api/src/api/models/GuildChannelOverride.ts index 485a185ae..ac88991bc 100644 --- a/fluxer_api/src/api/models/GuildChannelOverride.ts +++ b/fluxer_api/src/api/models/GuildChannelOverride.ts @@ -10,6 +10,7 @@ export class GuildChannelOverride { readonly muted: boolean; readonly muteConfig: MuteConfiguration | null; readonly unreadBadges: ChannelMessageNotifications | null; + readonly flags: number | null; constructor(override: ChannelOverride) { this.collapsed = override.collapsed ?? false; @@ -17,6 +18,7 @@ export class GuildChannelOverride { this.muted = override.muted ?? false; this.muteConfig = override.mute_config ? new MuteConfiguration(override.mute_config) : null; this.unreadBadges = (override.unread_badges ?? null) as ChannelMessageNotifications | null; + this.flags = override.flags ?? null; } toChannelOverride(): ChannelOverride { @@ -26,6 +28,7 @@ export class GuildChannelOverride { muted: this.muted, mute_config: this.muteConfig?.toMuteConfig() ?? null, unread_badges: this.unreadBadges, + ...(this.flags !== null ? {flags: this.flags} : {}), }; } } diff --git a/fluxer_api/src/api/models/PushSubscription.ts b/fluxer_api/src/api/models/PushSubscription.ts index f283a6c5a..c700d37f1 100644 --- a/fluxer_api/src/api/models/PushSubscription.ts +++ b/fluxer_api/src/api/models/PushSubscription.ts @@ -14,6 +14,7 @@ export class PushSubscription { readonly platform: PushSubscriptionPlatform; readonly appId: string | null; readonly providerEnvironment: string | null; + readonly threadChannels: boolean; constructor(row: PushSubscriptionRow) { this.userId = row.user_id; @@ -26,6 +27,7 @@ export class PushSubscription { this.platform = row.platform ?? 'web_push'; this.appId = row.app_id ?? null; this.providerEnvironment = row.provider_environment ?? null; + this.threadChannels = row.thread_channels === true; } toRow(): PushSubscriptionRow { @@ -40,6 +42,7 @@ export class PushSubscription { platform: this.platform, app_id: this.appId, provider_environment: this.providerEnvironment, + ...(this.threadChannels ? {thread_channels: true} : {}), }; } } diff --git a/fluxer_api/src/api/models/ReadState.ts b/fluxer_api/src/api/models/ReadState.ts index ba28be267..cd4c35dff 100644 --- a/fluxer_api/src/api/models/ReadState.ts +++ b/fluxer_api/src/api/models/ReadState.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; +import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; import type {ReadStateRow} from '@app/api/database/types/ChannelTypes'; type ReadStateSourceRow = ReadStateRow & {version?: bigint | number | null}; @@ -12,6 +12,8 @@ export class ReadState { readonly mentionCount: number; readonly lastPinTimestamp: Date | null; readonly version: bigint; + readonly flags: number | null; + readonly guildId: GuildID | null; constructor(row: ReadStateSourceRow) { this.userId = row.user_id; @@ -20,6 +22,12 @@ export class ReadState { this.mentionCount = row.mention_count ?? 0; this.lastPinTimestamp = row.last_pin_timestamp ?? null; this.version = row.version == null ? 0n : BigInt(row.version); + this.flags = row.flags ?? null; + this.guildId = row.guild_id ?? null; + } + + get isMarked(): boolean { + return this.guildId !== null; } toRow(): ReadStateRow { @@ -29,6 +37,7 @@ export class ReadState { message_id: this.lastMessageId, mention_count: this.mentionCount, last_pin_timestamp: this.lastPinTimestamp, + ...(this.guildId !== null ? {flags: this.flags, guild_id: this.guildId} : {}), }; } } diff --git a/fluxer_api/src/api/models/ThreadMember.ts b/fluxer_api/src/api/models/ThreadMember.ts new file mode 100644 index 000000000..093d82aef --- /dev/null +++ b/fluxer_api/src/api/models/ThreadMember.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; +import type {ThreadMemberRow} from '@app/api/database/types/ThreadTypes'; +import {MuteConfiguration} from '@app/api/models/MuteConfiguration'; + +export class ThreadMember { + readonly threadId: ChannelID; + readonly userId: UserID; + readonly guildId: GuildID; + readonly parentId: ChannelID; + readonly joinTimestamp: Date; + readonly flags: number; + readonly muted: boolean; + readonly muteConfig: MuteConfiguration | null; + + constructor(row: ThreadMemberRow) { + this.threadId = row.thread_id; + this.userId = row.user_id; + this.guildId = row.guild_id; + this.parentId = row.parent_id; + this.joinTimestamp = row.join_timestamp; + this.flags = row.flags ?? 0; + this.muted = row.muted ?? false; + this.muteConfig = row.mute_config ? new MuteConfiguration(row.mute_config) : null; + } + + toRow(): ThreadMemberRow { + return { + thread_id: this.threadId, + user_id: this.userId, + guild_id: this.guildId, + parent_id: this.parentId, + join_timestamp: this.joinTimestamp, + flags: this.flags, + muted: this.muted, + mute_config: this.muteConfig?.toMuteConfig() ?? null, + }; + } +} diff --git a/fluxer_api/src/api/models/ThreadParentConfig.ts b/fluxer_api/src/api/models/ThreadParentConfig.ts new file mode 100644 index 000000000..a6ace43db --- /dev/null +++ b/fluxer_api/src/api/models/ThreadParentConfig.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID} from '@app/api/BrandedTypes'; +import type {ThreadParentConfigRow} from '@app/api/database/types/ThreadTypes'; +import {ForumTag} from '@app/api/models/ForumTag'; + +export class ThreadParentConfig { + readonly guildId: GuildID; + readonly channelId: ChannelID; + readonly flags: number; + readonly defaultAutoArchiveDuration: number | null; + readonly defaultThreadRateLimitPerUser: number | null; + readonly availableTags: Array; + readonly defaultReactionEmojiId: bigint | null; + readonly defaultReactionEmojiName: string | null; + readonly defaultSortOrder: number | null; + readonly defaultForumLayout: number | null; + readonly defaultTagSetting: string | null; + readonly hasThreads: boolean; + + constructor(row: ThreadParentConfigRow) { + this.guildId = row.guild_id; + this.channelId = row.channel_id; + this.flags = row.flags ?? 0; + this.defaultAutoArchiveDuration = row.default_auto_archive_duration ?? null; + this.defaultThreadRateLimitPerUser = row.default_thread_rate_limit_per_user ?? null; + this.availableTags = (row.available_tags ?? []).map((tag) => new ForumTag(tag)); + this.defaultReactionEmojiId = row.default_reaction_emoji_id ?? null; + this.defaultReactionEmojiName = row.default_reaction_emoji_name ?? null; + this.defaultSortOrder = row.default_sort_order ?? null; + this.defaultForumLayout = row.default_forum_layout ?? null; + this.defaultTagSetting = row.default_tag_setting ?? null; + this.hasThreads = row.has_threads ?? false; + } + + toRow(): ThreadParentConfigRow { + return { + guild_id: this.guildId, + channel_id: this.channelId, + flags: this.flags, + default_auto_archive_duration: this.defaultAutoArchiveDuration, + default_thread_rate_limit_per_user: this.defaultThreadRateLimitPerUser, + available_tags: this.availableTags.length > 0 ? this.availableTags.map((tag) => tag.toUdt()) : null, + default_reaction_emoji_id: this.defaultReactionEmojiId, + default_reaction_emoji_name: this.defaultReactionEmojiName, + default_sort_order: this.defaultSortOrder, + default_forum_layout: this.defaultForumLayout, + default_tag_setting: this.defaultTagSetting, + has_threads: this.hasThreads, + }; + } +} diff --git a/fluxer_api/src/api/models/ThreadState.ts b/fluxer_api/src/api/models/ThreadState.ts new file mode 100644 index 000000000..582c14031 --- /dev/null +++ b/fluxer_api/src/api/models/ThreadState.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; +import type {ThreadStateRow} from '@app/api/database/types/ThreadTypes'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ChannelFlags} from '@fluxer/constants/src/ThreadConstants'; + +export class ThreadState { + readonly threadId: ChannelID; + readonly guildId: GuildID; + readonly parentId: ChannelID; + readonly type: number; + readonly archived: boolean; + readonly locked: boolean; + readonly invitable: boolean | null; + readonly autoArchiveDuration: number; + readonly archiveTimestamp: Date | null; + readonly createdAt: Date; + readonly flags: number; + readonly appliedTags: Array; + readonly memberCount: number; + readonly memberIdsPreview: Array; + readonly hasStarter: boolean; + readonly stateVersion: number; + + constructor(row: ThreadStateRow) { + this.threadId = row.thread_id; + this.guildId = row.guild_id; + this.parentId = row.parent_id; + this.type = row.type; + this.archived = row.archived ?? false; + this.locked = row.locked ?? false; + this.invitable = row.type === ChannelTypes.PRIVATE_THREAD ? (row.invitable ?? true) : null; + this.autoArchiveDuration = row.auto_archive_duration; + this.archiveTimestamp = row.archive_timestamp ?? null; + this.createdAt = row.created_at; + this.flags = row.flags ?? 0; + this.appliedTags = row.applied_tags ?? []; + this.memberCount = row.member_count ?? 0; + this.memberIdsPreview = row.member_ids_preview ?? []; + this.hasStarter = row.has_starter ?? false; + this.stateVersion = row.state_version; + } + + get isPrivate(): boolean { + return this.type === ChannelTypes.PRIVATE_THREAD; + } + + get isPinned(): boolean { + return (this.flags & ChannelFlags.PINNED) !== 0; + } + + toRow(): ThreadStateRow { + return { + thread_id: this.threadId, + guild_id: this.guildId, + parent_id: this.parentId, + type: this.type, + archived: this.archived, + locked: this.locked, + invitable: this.invitable, + auto_archive_duration: this.autoArchiveDuration, + archive_timestamp: this.archiveTimestamp, + created_at: this.createdAt, + flags: this.flags, + applied_tags: this.appliedTags.length > 0 ? this.appliedTags : null, + member_count: this.memberCount, + member_ids_preview: this.memberIdsPreview.length > 0 ? this.memberIdsPreview : null, + has_starter: this.hasStarter, + state_version: this.stateVersion, + }; + } +} diff --git a/fluxer_api/src/api/models/ThreadStats.ts b/fluxer_api/src/api/models/ThreadStats.ts new file mode 100644 index 000000000..9268611bb --- /dev/null +++ b/fluxer_api/src/api/models/ThreadStats.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID} from '@app/api/BrandedTypes'; +import type {ThreadStatsRow} from '@app/api/database/types/ThreadTypes'; + +export class ThreadStats { + readonly threadId: ChannelID; + readonly messageCount: number; + readonly totalMessageSent: number; + + constructor(row: ThreadStatsRow) { + this.threadId = row.thread_id; + this.messageCount = Math.max(0, row.message_count ?? 0); + this.totalMessageSent = Math.max(0, row.total_message_sent ?? 0); + } + + static empty(threadId: ChannelID): ThreadStats { + return new ThreadStats({thread_id: threadId, message_count: 0, total_message_sent: 0}); + } +} diff --git a/fluxer_api/src/api/oauth/OAuth2Controller.ts b/fluxer_api/src/api/oauth/OAuth2Controller.ts index 8ba1e9a70..179d01a9f 100644 --- a/fluxer_api/src/api/oauth/OAuth2Controller.ts +++ b/fluxer_api/src/api/oauth/OAuth2Controller.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Config} from '@app/api/Config'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {requireOAuth2BearerToken, requireOAuth2Scope} from '@app/api/middleware/OAuth2ScopeMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; @@ -124,6 +125,8 @@ export function OAuth2Controller(app: HonoApp) { await ctx.get('oauth2RequestService').authorizeConsent({ body, userId: user.id, + clientFeatures: ctx.get('clientFeatures'), + viewer: viewerFromCtx(ctx), requestCache: ctx.get('requestCache'), }), ); diff --git a/fluxer_api/src/api/oauth/OAuth2RequestService.ts b/fluxer_api/src/api/oauth/OAuth2RequestService.ts index 29245b8a5..4e057ef50 100644 --- a/fluxer_api/src/api/oauth/OAuth2RequestService.ts +++ b/fluxer_api/src/api/oauth/OAuth2RequestService.ts @@ -5,7 +5,9 @@ import type {SudoVerificationBody} from '@app/api/auth/services/SudoVerification import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService'; import {createApplicationID, createChannelID, createGuildID, createRoleID, type UserID} from '@app/api/BrandedTypes'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; +import {guildActive, type ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildService} from '@app/api/guild/services/GuildService'; +import {resolveProtectedBitActor, stripThreadPermissionBits} from '@app/api/guild/services/ThreadPermissionBits'; import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {ApplicationService} from '@app/api/oauth/ApplicationService'; @@ -26,7 +28,9 @@ import {isSignInRefused} from '@app/api/user/UserHelpers'; import {mapUserToOAuthResponse, mapUserToPartialResponse} from '@app/api/user/UserMappers'; import {verifyPassword} from '@app/api/utils/PasswordUtils'; import {canAuthorizeBotInvite, normalizeBotInvitePermissions} from '@fluxer/constants/src/BotPermissionUtils'; +import {ALL_PERMISSIONS} from '@fluxer/constants/src/ChannelConstants'; import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants'; +import {THREAD_AWARE_ALL_PERMISSIONS, withImplicitThreadBits} from '@fluxer/constants/src/ThreadPermissionUtils'; import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {InvalidPermissionsIntegerError} from '@fluxer/errors/src/domains/core/InvalidPermissionsIntegerError'; @@ -196,6 +200,8 @@ export class OAuth2RequestService { async authorizeConsent(params: { body: AuthorizeConsentRequest; userId: UserID; + clientFeatures: ReadonlySet; + viewer: ThreadViewer; requestCache: RequestCache; }): Promise { const scopeStr = params.body.scope; @@ -207,6 +213,7 @@ export class OAuth2RequestService { if (guildId && channelId) { throw InputValidationError.create('channel_id', 'channel_id cannot be used with guild_id'); } + const botPermissionMask = guildId && guildActive(guildId) ? THREAD_AWARE_ALL_PERMISSIONS : ALL_PERMISSIONS; let requestedPermissions: bigint | null = null; if (params.body.permissions !== undefined) { try { @@ -217,7 +224,17 @@ export class OAuth2RequestService { if (requestedPermissions < 0) { throw new InvalidPermissionsNegativeError(); } - requestedPermissions = normalizeBotInvitePermissions(requestedPermissions); + requestedPermissions = normalizeBotInvitePermissions(requestedPermissions, botPermissionMask); + if (guildId && botPermissionMask !== ALL_PERMISSIONS) { + const actor = await resolveProtectedBitActor({ + guildId, + userId: params.userId, + clientFeatures: params.clientFeatures, + viewer: params.viewer, + isBot: async () => false, + }); + if (!actor.threadBits?.writer) requestedPermissions = stripThreadPermissionBits(requestedPermissions); + } } if (!isBotOnly && responseType !== 'code') { throw new InvalidResponseTypeForNonBotError(); @@ -258,8 +275,10 @@ export class OAuth2RequestService { }); if ( !canAuthorizeBotInvite({ - userPermissions, + userPermissions: + botPermissionMask === ALL_PERMISSIONS ? userPermissions : withImplicitThreadBits(userPermissions), requestedPermissions, + mask: botPermissionMask, }) ) { throw new MissingPermissionsError(); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 070697f57..37e700e39 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -4678,6 +4678,80 @@ ] } }, + "/channels/{channel_id}/messages/{message_id}/threads": { + "post": { + "operationId": "start_thread_from_message", + "summary": "Start a thread from a message", + "tags": ["Channels"], + "responses": { + "201": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThreadChannelResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Creates a public thread from an existing message in a text channel. The thread shares the ID of the message, so a message can start one thread.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the message", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the message" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StartThreadFromMessageRequest"}}} + } + } + }, "/channels/{channel_id}/permissions/{overwrite_id}": { "put": { "operationId": "set_channel_permission_overwrite", @@ -5007,6 +5081,73 @@ ] } }, + "/channels/{channel_id}/post-data": { + "post": { + "operationId": "get_channel_post_data", + "summary": "Get forum post data", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThreadPostDataResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns the owner and first message of each requested post in a forum or media channel. Requires the read message history permission.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThreadPostDataRequest"}}} + } + } + }, "/channels/{channel_id}/recipients/{user_id}": { "put": { "operationId": "add_group_dm_recipient", @@ -5287,11 +5428,315 @@ ] } }, - "/channels/{channel_id}/typing": { + "/channels/{channel_id}/tags": { "post": { - "operationId": "indicate_typing", - "summary": "Indicate typing activity", - "tags": ["Channels", "Messages"], + "operationId": "create_forum_tag", + "summary": "Create a forum tag", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChannelResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Adds a tag to a forum or media channel. Requires the manage channels permission. Returns the updated channel.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ForumTagRequest"}}} + } + } + }, + "/channels/{channel_id}/tags/{tag_id}": { + "put": { + "operationId": "update_forum_tag", + "summary": "Update a forum tag", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChannelResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Replaces a tag of a forum or media channel. Requires the manage channels permission. Returns the updated channel.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "tag_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the forum tag", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the forum tag" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ForumTagRequest"}}} + } + }, + "delete": { + "operationId": "delete_forum_tag", + "summary": "Delete a forum tag", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChannelResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Removes a tag from a forum or media channel. Requires the manage channels permission. Returns the updated channel.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "tag_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the forum tag", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the forum tag" + } + ] + } + }, + "/channels/{channel_id}/thread-members": { + "get": { + "operationId": "list_thread_members", + "summary": "List thread members", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThreadMemberListResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns thread members ordered by user ID. Paginate with after and limit. Set with_member to include the guild member object of each thread member.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "with_member", + "in": "query", + "required": false, + "schema": { + "description": "Whether to include a guild member object for each thread member", + "default": "false", + "type": "string" + }, + "description": "Whether to include a guild member object for each thread member" + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "description": "Get thread members after this user ID", + "$ref": "#/components/schemas/SnowflakeType" + }, + "description": "Get thread members after this user ID" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "description": "Max number of thread members to return (1-100, default 100)", + "default": "100", + "type": "string" + }, + "description": "Max number of thread members to return (1-100, default 100)" + } + ] + } + }, + "/channels/{channel_id}/thread-members/@me": { + "put": { + "operationId": "join_thread", + "summary": "Join a thread", + "tags": ["Channels"], "responses": { "204": {"description": "No Content"}, "400": { @@ -5333,7 +5778,8 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Notifies other users in the channel that you are actively typing. Typing indicators typically expire after a short period (usually 10 seconds). Returns 204 No Content. Commonly called repeatedly while the user is composing a message.", + "description": "Adds the current user to the thread. The thread must not be archived.", + "x-fluxer-experiment": "channel_threads", "security": [{"botToken": []}, {"sessionToken": []}], "parameters": [ { @@ -5342,6 +5788,926 @@ "required": true, "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, "description": "The ID of the channel" + }, + { + "name": "location", + "in": "query", + "required": false, + "schema": {"description": "Accepted and ignored", "type": "string", "maxLength": 100}, + "description": "Accepted and ignored" + } + ] + }, + "delete": { + "operationId": "leave_thread", + "summary": "Leave a thread", + "tags": ["Channels"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Removes the current user from the thread. The thread must not be archived.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "location", + "in": "query", + "required": false, + "schema": {"description": "Accepted and ignored", "type": "string", "maxLength": 100}, + "description": "Accepted and ignored" + } + ] + } + }, + "/channels/{channel_id}/thread-members/@me/settings": { + "patch": { + "operationId": "update_thread_member_settings", + "summary": "Update thread settings", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {"anyOf": [{"$ref": "#/components/schemas/ThreadMemberResponse"}, {"type": "null"}]} + } + } + }, + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Updates the current user's notification settings for a thread they are a member of. Returns the thread member, or 204 when nothing changed.", + "x-fluxer-experiment": "channel_threads", + "security": [{"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + } + ], + "requestBody": { + "required": false, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThreadMemberSettingsRequest"}}} + } + } + }, + "/channels/{channel_id}/thread-members/{user_id}": { + "get": { + "operationId": "get_thread_member", + "summary": "Get a thread member", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThreadMemberResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns the thread member object of the user when the user is a member of the thread.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + }, + { + "name": "with_member", + "in": "query", + "required": false, + "schema": { + "description": "Whether to include a guild member object for the thread member", + "default": "false", + "type": "string" + }, + "description": "Whether to include a guild member object for the thread member" + } + ] + }, + "put": { + "operationId": "add_thread_member", + "summary": "Add a thread member", + "tags": ["Channels"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Adds another guild member to the thread. Requires permission to send messages in threads, and the thread must not be archived.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + }, + { + "name": "location", + "in": "query", + "required": false, + "schema": {"description": "Accepted and ignored", "type": "string", "maxLength": 100}, + "description": "Accepted and ignored" + } + ] + }, + "delete": { + "operationId": "remove_thread_member", + "summary": "Remove a thread member", + "tags": ["Channels"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Removes a member from the thread. Requires the manage threads permission, or being the creator of a private thread. The thread must not be archived.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the user" + }, + { + "name": "location", + "in": "query", + "required": false, + "schema": {"description": "Accepted and ignored", "type": "string", "maxLength": 100}, + "description": "Accepted and ignored" + } + ] + } + }, + "/channels/{channel_id}/threads": { + "post": { + "operationId": "start_thread", + "summary": "Start a thread", + "tags": ["Channels"], + "responses": { + "201": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StartForumThreadResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Creates a thread that is not attached to an existing message. In a text channel the thread type is required. In a forum or media channel this creates a post, and the body carries the first message. The body can also be sent as multipart form data with the JSON in a payload_json field, and a post can attach files as files[n] parts.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/StartThreadRequestBody"}}, + "multipart/form-data": {"schema": {"$ref": "#/components/schemas/StartThreadMultipartRequest"}} + } + } + } + }, + "/channels/{channel_id}/threads/archived/private": { + "get": { + "operationId": "list_private_archived_threads", + "summary": "List private archived threads", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArchivedThreadsResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns archived private threads of the text channel, most recently archived first. Requires the read message history and manage threads permissions.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "description": "Get threads archived before this timestamp", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?$" + }, + "description": "Get threads archived before this timestamp" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "description": "Max number of threads to return (2-100, default 50)", + "default": "50", + "type": "string" + }, + "description": "Max number of threads to return (2-100, default 50)" + } + ] + } + }, + "/channels/{channel_id}/threads/archived/public": { + "get": { + "operationId": "list_public_archived_threads", + "summary": "List public archived threads", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArchivedThreadsResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns archived public threads of the channel, most recently archived first. Requires the read message history permission.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "description": "Get threads archived before this timestamp", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?$" + }, + "description": "Get threads archived before this timestamp" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "description": "Max number of threads to return (2-100, default 50)", + "default": "50", + "type": "string" + }, + "description": "Max number of threads to return (2-100, default 50)" + } + ] + } + }, + "/channels/{channel_id}/threads/search": { + "get": { + "operationId": "search_threads", + "summary": "Search threads", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThreadSearchResult"}}} + }, + "202": { + "description": "Accepted", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SearchIndexNotReadyResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns threads of the channel that match the search. Requires the read message history permission. While the search index of the guild is being built, responds with 202 and a body that says when to retry.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "name", + "in": "query", + "required": false, + "schema": { + "description": "Text to look for in thread names (max 100 characters)", + "type": "string", + "maxLength": 100 + }, + "description": "Text to look for in thread names (max 100 characters)" + }, + { + "name": "slop", + "in": "query", + "required": false, + "schema": { + "description": "Accepted for compatibility and ignored (max 100, default 2)", + "default": "2", + "type": "string" + }, + "description": "Accepted for compatibility and ignored (max 100, default 2)" + }, + { + "name": "tag", + "in": "query", + "required": false, + "schema": { + "description": "Tag IDs to filter by (max 20)", + "maxItems": 20, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"} + }, + "description": "Tag IDs to filter by (max 20)" + }, + { + "name": "tag_setting", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/ForumTagSettingSchema"}, + "description": "How posts are filtered when searching by several tags" + }, + { + "name": "archived", + "in": "query", + "required": false, + "schema": { + "description": "Whether to return only archived or only active threads (default both)", + "type": "string" + }, + "description": "Whether to return only archived or only active threads (default both)" + }, + { + "name": "sort_by", + "in": "query", + "required": false, + "schema": {"default": "last_message_time", "$ref": "#/components/schemas/ThreadSearchSortBySchema"}, + "description": "The sorting algorithm to use" + }, + { + "name": "sort_order", + "in": "query", + "required": false, + "schema": {"default": "desc", "$ref": "#/components/schemas/ThreadSearchSortOrderSchema"}, + "description": "The direction to sort" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "description": "Max number of threads to return (1-25, default 25)", + "default": "25", + "type": "string" + }, + "description": "Max number of threads to return (1-25, default 25)" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": {"description": "Number of threads to skip (max 9975)", "default": "0", "type": "string"}, + "description": "Number of threads to skip (max 9975)" + }, + { + "name": "max_id", + "in": "query", + "required": false, + "schema": { + "description": "Get threads before this thread ID", + "$ref": "#/components/schemas/SnowflakeType" + }, + "description": "Get threads before this thread ID" + }, + { + "name": "min_id", + "in": "query", + "required": false, + "schema": {"description": "Get threads after this thread ID", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "Get threads after this thread ID" + } + ] + } + }, + "/channels/{channel_id}/typing": { + "post": { + "operationId": "indicate_typing", + "summary": "Indicate typing activity", + "tags": ["Channels", "Messages"], + "responses": { + "200": {"description": "Success"}, + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Notifies other users in the channel that you are actively typing. Typing indicators typically expire after a short period (usually 10 seconds). Returns 204 No Content, or 200 with a JSON body holding the remaining slowmode cooldowns in message_send_cooldown_ms and thread_create_cooldown_ms when the user is rate limited. Commonly called repeatedly while the user is composing a message.", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + } + ] + } + }, + "/channels/{channel_id}/users/@me/threads/archived/private": { + "get": { + "operationId": "list_joined_private_archived_threads", + "summary": "List joined private archived threads", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArchivedThreadsResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns archived private threads of the text channel that the current user joined, newest first. Requires the read message history permission.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}, {"sessionToken": []}], + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "description": "Get threads before this thread ID", + "$ref": "#/components/schemas/SnowflakeType" + }, + "description": "Get threads before this thread ID" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "description": "Max number of threads to return (2-100, default 50)", + "default": "50", + "type": "string" + }, + "description": "Max number of threads to return (2-100, default 50)" } ] } @@ -9562,6 +10928,69 @@ ] } }, + "/guilds/{guild_id}/threads/active": { + "get": { + "operationId": "list_guild_active_threads", + "summary": "List active guild threads", + "tags": ["Channels"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ActiveThreadsResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns every active thread in the guild that the bot can view, newest first, with a thread member object for each thread the bot joined.", + "x-fluxer-experiment": "channel_threads", + "security": [{"botToken": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the guild", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + } + ] + } + }, "/guilds/{guild_id}/transfer-ownership": { "post": { "operationId": "transfer_guild_ownership", @@ -21151,6 +22580,21 @@ "required": false, "schema": {"description": "Whether to wait for the webhook response", "default": "false", "type": "string"}, "description": "Whether to wait for the webhook response" + }, + { + "name": "thread_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "description": "ID of a thread in the webhook channel to target instead of the channel itself", + "x-fluxer-experiment": "channel_threads" + }, + {"type": "null"} + ] + } } ], "requestBody": { @@ -21215,6 +22659,21 @@ "required": true, "schema": {"description": "The webhook token", "type": "string"}, "description": "The webhook token" + }, + { + "name": "thread_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "description": "ID of a thread in the webhook channel to target instead of the channel itself", + "x-fluxer-experiment": "channel_threads" + }, + {"type": "null"} + ] + } } ], "requestBody": { @@ -21347,6 +22806,21 @@ "required": true, "schema": {"description": "The ID of the message", "$ref": "#/components/schemas/SnowflakeType"}, "description": "The ID of the message" + }, + { + "name": "thread_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "description": "ID of a thread in the webhook channel to target instead of the channel itself", + "x-fluxer-experiment": "channel_threads" + }, + {"type": "null"} + ] + } } ] }, @@ -21412,6 +22886,21 @@ "required": true, "schema": {"description": "The ID of the message", "$ref": "#/components/schemas/SnowflakeType"}, "description": "The ID of the message" + }, + { + "name": "thread_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "description": "ID of a thread in the webhook channel to target instead of the channel itself", + "x-fluxer-experiment": "channel_threads" + }, + {"type": "null"} + ] + } } ], "requestBody": { @@ -21478,6 +22967,21 @@ "required": true, "schema": {"description": "The ID of the message", "$ref": "#/components/schemas/SnowflakeType"}, "description": "The ID of the message" + }, + { + "name": "thread_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "description": "ID of a thread in the webhook channel to target instead of the channel itself", + "x-fluxer-experiment": "channel_threads" + }, + {"type": "null"} + ] + } } ] } @@ -21538,6 +23042,21 @@ "required": true, "schema": {"description": "The webhook token", "type": "string"}, "description": "The webhook token" + }, + { + "name": "thread_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "description": "ID of a thread in the webhook channel to target instead of the channel itself", + "x-fluxer-experiment": "channel_threads" + }, + {"type": "null"} + ] + } } ], "requestBody": { @@ -21773,7 +23292,7 @@ "$ref": "#/components/schemas/SnowflakeStringType" }, "message_id": { - "description": "The ID of the referenced message, absent on a channel follow system message", + "description": "The ID of the referenced message, absent on a channel follow system message and on thread created messages that reference only a thread", "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}] }, "guild_id": { @@ -21825,6 +23344,10 @@ {"type": "null"} ] }, + "thread": { + "description": "The thread started from this message, when the viewer can see threads", + "$ref": "#/components/schemas/ThreadChannelResponse" + }, "referenced_message": { "description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message has no default reference. Clients must tell null apart from absent by key presence.", "anyOf": [ @@ -21936,7 +23459,7 @@ "$ref": "#/components/schemas/SnowflakeStringType" }, "message_id": { - "description": "The ID of the referenced message, absent on a channel follow system message", + "description": "The ID of the referenced message, absent on a channel follow system message and on thread created messages that reference only a thread", "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}] }, "guild_id": { @@ -21990,6 +23513,10 @@ }, {"type": "null"} ] + }, + "thread": { + "description": "The thread started from this message, when the viewer can see threads", + "$ref": "#/components/schemas/ThreadChannelResponse" } }, "required": [ @@ -22637,6 +24164,18 @@ } } } + }, + "thread_name": { + "description": "Name of the post to create when the webhook belongs to a forum or media channel (1-100 characters)", + "type": "string", + "x-fluxer-experiment": "channel_threads" + }, + "applied_tags": { + "maxItems": 5, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "IDs of the tags to apply to the new post (max 5)", + "x-fluxer-experiment": "channel_threads" } } }, @@ -22683,6 +24222,18 @@ "description": "Array of attachments uploaded through the presigned upload endpoint", "type": "array", "items": {"$ref": "#/components/schemas/ClientUploadedAttachmentRequest"} + }, + "thread_name": { + "description": "Name of the post to create when the webhook belongs to a forum or media channel (1-100 characters)", + "type": "string", + "x-fluxer-experiment": "channel_threads" + }, + "applied_tags": { + "maxItems": 5, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "IDs of the tags to apply to the new post (max 5)", + "x-fluxer-experiment": "channel_threads" } } }, @@ -24944,6 +26495,10 @@ }, {"type": "null"} ] + }, + "flags": { + "description": "Channel override flags (NEW_FORUM_THREADS_OFF 1<<13, NEW_FORUM_THREADS_ON 1<<14)", + "$ref": "#/components/schemas/Int32Type" } }, "required": ["collapsed", "message_notifications", "muted"] @@ -25046,6 +26601,10 @@ }, {"type": "null"} ] + }, + "flags": { + "description": "Channel override flags (NEW_FORUM_THREADS_OFF 1<<13, NEW_FORUM_THREADS_ON 1<<14)", + "$ref": "#/components/schemas/Int32Type" } }, "required": ["collapsed", "message_notifications", "muted", "mute_config"], @@ -25572,6 +27131,70 @@ "type": "object", "propertyNames": {"type": "string"}, "additionalProperties": {"type": "string", "minLength": 1, "maxLength": 32} + }, + "flags": {"description": "Channel flags", "$ref": "#/components/schemas/Int32Type"}, + "thread_metadata": { + "description": "Thread-specific fields", + "$ref": "#/components/schemas/ThreadMetadataResponse" + }, + "applied_tags": { + "description": "IDs of the tags applied to a forum or media post", + "maxItems": 5, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeStringType"} + }, + "message_count": { + "description": "Messages in the thread, excluding the starter and deleted messages", + "$ref": "#/components/schemas/Int32Type" + }, + "total_message_sent": { + "description": "Messages ever sent in the thread, never decremented", + "$ref": "#/components/schemas/Int32Type" + }, + "member_count": { + "description": "Approximate number of thread members, capped at 50", + "$ref": "#/components/schemas/Int32Type" + }, + "member_ids_preview": { + "description": "IDs of the most recently joined members of a forum or media post, newest first", + "maxItems": 8, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeStringType"} + }, + "member": { + "description": "The thread member object for the current user", + "$ref": "#/components/schemas/ThreadMemberResponse" + }, + "default_auto_archive_duration": { + "description": "Default auto archive duration for new threads", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode copied onto new threads", + "$ref": "#/components/schemas/Int32Type" + }, + "available_tags": { + "description": "Tags that can be applied to posts", + "maxItems": 20, + "type": "array", + "items": {"$ref": "#/components/schemas/ForumTagResponse"} + }, + "default_reaction_emoji": { + "description": "Default reaction for posts", + "anyOf": [{"$ref": "#/components/schemas/DefaultReactionEmojiResponse"}, {"type": "null"}] + }, + "default_sort_order": { + "description": "Default sort order for posts", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "default_forum_layout": { + "description": "Default layout for forum posts", + "$ref": "#/components/schemas/Int32Type" + }, + "default_tag_setting": { + "description": "Default tag search setting", + "type": "string", + "enum": ["match_some", "match_all"] } }, "required": ["id", "type"], @@ -27776,6 +29399,12 @@ "approximate_presence_count": { "description": "Approximate online member count (only when with_counts is true)", "$ref": "#/components/schemas/Int32Type" + }, + "threads_active": { + "type": "boolean", + "const": true, + "description": "Present and true when threads, forum and media channels are active in this guild for the requesting client. Only set on the current user guild list", + "x-fluxer-experiment": "channel_threads" } }, "required": [ @@ -27797,6 +29426,23 @@ ], "additionalProperties": false }, + "ActiveThreadsResponse": { + "type": "object", + "properties": { + "threads": { + "type": "array", + "items": {"$ref": "#/components/schemas/ThreadChannelResponse"}, + "description": "The active threads" + }, + "members": { + "type": "array", + "items": {"$ref": "#/components/schemas/ThreadMemberResponse"}, + "description": "A thread member object for each returned thread the user joined" + } + }, + "required": ["threads", "members"], + "additionalProperties": false + }, "GuildStickerUpdateRequest": { "type": "object", "properties": { @@ -28531,7 +30177,9 @@ {"$ref": "#/components/schemas/GuildAnnouncementChannelCreateRequest"}, {"$ref": "#/components/schemas/GuildVoiceChannelCreateRequest"}, {"$ref": "#/components/schemas/GuildCategoryChannelCreateRequest"}, - {"$ref": "#/components/schemas/GuildLinkChannelCreateRequest"} + {"$ref": "#/components/schemas/GuildLinkChannelCreateRequest"}, + {"$ref": "#/components/schemas/GuildForumChannelCreateRequest"}, + {"$ref": "#/components/schemas/GuildMediaChannelCreateRequest"} ] }, "GuildBanCreateRequest": { @@ -28601,7 +30249,10 @@ "MESSAGE_DELETE", "MESSAGE_BULK_DELETE", "MESSAGE_PIN", - "MESSAGE_UNPIN" + "MESSAGE_UNPIN", + "THREAD_CREATE", + "THREAD_UPDATE", + "THREAD_DELETE" ], "x-enumDescriptions": [ "Guild settings were updated", @@ -28638,11 +30289,14 @@ "Message was deleted", "Messages were bulk deleted", "Message was pinned", - "Message was unpinned" + "Message was unpinned", + "Thread was created", + "Thread was updated", + "Thread was deleted" ], "enum": [ 1, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 40, 41, 42, 50, 51, 52, 60, 61, 62, - 90, 91, 92, 72, 73, 74, 75 + 90, 91, 92, 72, 73, 74, 75, 110, 111, 112 ] }, "Int32Type": {"type": "integer", "minimum": 0, "maximum": 2147483647, "format": "int32"}, @@ -28663,6 +30317,11 @@ "type": "array", "items": {"$ref": "#/components/schemas/AuditLogWebhookResponse"}, "description": "Webhooks referenced in the audit log entries" + }, + "threads": { + "description": "Threads referenced in the audit log entries, when the viewer can see threads", + "type": "array", + "items": {"$ref": "#/components/schemas/ThreadChannelResponse"} } }, "required": ["audit_log_entries", "users", "webhooks"], @@ -28870,6 +30529,7 @@ "type": "object", "properties": { "domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"}, + "channel_threads": {"$ref": "#/components/schemas/ChannelThreadsAssignmentResponse"}, "plutonium_page": {"$ref": "#/components/schemas/PlutoniumPageAssignmentResponse"} }, "additionalProperties": false @@ -29072,6 +30732,348 @@ "required": ["id", "guild_id", "channel_id", "name", "type", "token", "user"], "additionalProperties": false }, + "ArchivedThreadsResponse": { + "type": "object", + "properties": { + "threads": { + "type": "array", + "items": {"$ref": "#/components/schemas/ThreadChannelResponse"}, + "description": "The archived threads" + }, + "members": { + "type": "array", + "items": {"$ref": "#/components/schemas/ThreadMemberResponse"}, + "description": "A thread member object for each returned thread the user joined" + }, + "has_more": {"type": "boolean", "description": "Whether there are potentially more threads to fetch"} + }, + "required": ["threads", "members", "has_more"], + "additionalProperties": false + }, + "ThreadSearchSortOrderSchema": { + "type": "string", + "enum": ["asc", "desc"], + "description": "The direction to sort" + }, + "ThreadSearchSortBySchema": { + "type": "string", + "enum": ["last_message_time", "archive_time", "relevance", "creation_time"], + "description": "The sorting algorithm to use" + }, + "ForumTagSettingSchema": { + "type": "string", + "enum": ["match_some", "match_all"], + "description": "How posts are filtered when searching by several tags" + }, + "SearchIndexNotReadyResponse": { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "SEARCH_INDEX_NOT_READY", + "description": "Machine-readable code of the response" + }, + "message": {"type": "string", "description": "Human-readable description of the response"}, + "documents_indexed": { + "description": "Always 0 while the index is being built", + "$ref": "#/components/schemas/Int32Type" + }, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying the search"} + }, + "required": ["code", "message", "documents_indexed", "retry_after"], + "additionalProperties": false, + "x-fluxer-experiment": "channel_threads" + }, + "ThreadSearchResult": { + "anyOf": [ + {"$ref": "#/components/schemas/ThreadSearchResponse"}, + {"$ref": "#/components/schemas/SearchIndexNotReadyResponse"} + ], + "x-fluxer-experiment": "channel_threads" + }, + "StartThreadMultipartRequest": { + "type": "object", + "properties": { + "payload_json": {"type": "string", "description": "The JSON-encoded request body"}, + "files[0]": { + "description": "A file for the first message of a post, referenced by attachment id 0. Further files go in files[1], files[2] and so on", + "type": "string", + "format": "binary", + "contentEncoding": "binary" + } + }, + "required": ["payload_json"], + "x-fluxer-experiment": "channel_threads" + }, + "StartThreadRequestBody": { + "anyOf": [ + {"$ref": "#/components/schemas/StartThreadRequest"}, + {"$ref": "#/components/schemas/StartForumThreadRequest"} + ] + }, + "StartForumThreadResponse": { + "type": "object", + "properties": { + "id": { + "description": "The unique identifier (snowflake) for this channel", + "$ref": "#/components/schemas/SnowflakeStringType" + }, + "guild_id": { + "description": "The ID of the guild this channel belongs to", + "$ref": "#/components/schemas/SnowflakeStringType" + }, + "name": {"description": "The name of the channel", "type": ["string", "null"]}, + "topic": {"description": "The topic of the channel", "type": ["string", "null"]}, + "url": { + "description": "The URL associated with the channel", + "anyOf": [{"type": "string", "format": "uri"}, {"type": "null"}] + }, + "icon": {"description": "The icon hash of the channel (for group DMs)", "type": ["string", "null"]}, + "owner_id": { + "description": "The ID of the owner of the channel (for group DMs)", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}] + }, + "type": {"$ref": "#/components/schemas/ChannelType"}, + "position": {"description": "The sorting position of the channel", "$ref": "#/components/schemas/Int32Type"}, + "parent_id": { + "description": "The ID of the parent category for this channel", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}] + }, + "bitrate": { + "description": "The bitrate of the voice channel in bits per second", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "user_limit": { + "description": "The maximum number of users allowed in the voice channel", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "voice_connection_limit": { + "description": "The maximum active voice connections allowed per user in the voice channel", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "rtc_region": {"description": "The voice region ID for the voice channel", "type": ["string", "null"]}, + "last_message_id": { + "description": "The ID of the last message sent in this channel", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}] + }, + "last_pin_timestamp": { + "description": "The ISO 8601 timestamp of when the last pinned message was pinned", + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + {"type": "null"} + ] + }, + "permission_overwrites": { + "description": "The permission overwrites for this channel", + "type": "array", + "items": {"$ref": "#/components/schemas/ChannelOverwriteResponse"} + }, + "recipients": { + "description": "The recipients of the DM channel", + "maxItems": 49, + "type": "array", + "items": {"$ref": "#/components/schemas/UserPartialResponse"} + }, + "nsfw": { + "description": "Whether the channel is marked as NSFW (effective value, walking channel → category → guild)", + "type": "boolean" + }, + "nsfw_override": { + "description": "Per-channel adult-content override; null means inherit from parent category and then guild. Categories use this same field as their own override.", + "type": ["boolean", "null"] + }, + "content_warning_level": { + "description": "Channel-level content warning override (0=inherit, 1=force-warn)", + "$ref": "#/components/schemas/ContentWarningLevel" + }, + "content_warning_text": { + "description": "Custom channel content warning text (max 200 characters); null inherits from parent or guild", + "anyOf": [{"type": "string", "maxLength": 200}, {"type": "null"}] + }, + "rate_limit_per_user": { + "description": "The slowmode rate limit in seconds", + "$ref": "#/components/schemas/Int32Type" + }, + "nicks": { + "description": "Custom nicknames for users in this channel (for group DMs)", + "type": "object", + "propertyNames": {"type": "string"}, + "additionalProperties": {"type": "string", "minLength": 1, "maxLength": 32} + }, + "flags": {"description": "Channel flags", "$ref": "#/components/schemas/Int32Type"}, + "thread_metadata": { + "description": "Thread-specific fields", + "$ref": "#/components/schemas/ThreadMetadataResponse" + }, + "applied_tags": { + "description": "IDs of the tags applied to a forum or media post", + "maxItems": 5, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeStringType"} + }, + "message_count": { + "description": "Messages in the thread, excluding the starter and deleted messages", + "$ref": "#/components/schemas/Int32Type" + }, + "total_message_sent": { + "description": "Messages ever sent in the thread, never decremented", + "$ref": "#/components/schemas/Int32Type" + }, + "member_count": { + "description": "Approximate number of thread members, capped at 50", + "$ref": "#/components/schemas/Int32Type" + }, + "member_ids_preview": { + "description": "IDs of the most recently joined members of a forum or media post, newest first", + "maxItems": 8, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeStringType"} + }, + "member": { + "description": "The thread member object for the current user", + "$ref": "#/components/schemas/ThreadMemberResponse" + }, + "default_auto_archive_duration": { + "description": "Default auto archive duration for new threads", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode copied onto new threads", + "$ref": "#/components/schemas/Int32Type" + }, + "available_tags": { + "description": "Tags that can be applied to posts", + "maxItems": 20, + "type": "array", + "items": {"$ref": "#/components/schemas/ForumTagResponse"} + }, + "default_reaction_emoji": { + "description": "Default reaction for posts", + "anyOf": [{"$ref": "#/components/schemas/DefaultReactionEmojiResponse"}, {"type": "null"}] + }, + "default_sort_order": { + "description": "Default sort order for posts", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "default_forum_layout": { + "description": "Default layout for forum posts", + "$ref": "#/components/schemas/Int32Type" + }, + "default_tag_setting": { + "description": "Default tag search setting", + "type": "string", + "enum": ["match_some", "match_all"] + }, + "message": { + "description": "The first message of the post", + "$ref": "#/components/schemas/MessageResponseSchema" + } + }, + "required": ["id", "type"], + "additionalProperties": false, + "x-fluxer-experiment": "channel_threads" + }, + "ThreadMemberResponse": { + "type": "object", + "properties": { + "id": {"description": "The ID of the thread", "$ref": "#/components/schemas/SnowflakeStringType"}, + "user_id": {"description": "The ID of the user", "$ref": "#/components/schemas/SnowflakeStringType"}, + "join_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When the user last joined the thread" + }, + "flags": {"description": "Thread member flags", "$ref": "#/components/schemas/Int32Type"}, + "muted": {"description": "Whether the user has muted the thread (current user only)", "type": "boolean"}, + "mute_config": { + "description": "The mute configuration for the thread (current user only)", + "anyOf": [ + { + "type": "object", + "properties": { + "end_time": {"description": "ISO8601 timestamp of when the mute expires", "type": ["string", "null"]}, + "selected_time_window": { + "description": "The selected mute duration in seconds", + "$ref": "#/components/schemas/Int32Type" + } + }, + "required": ["end_time", "selected_time_window"], + "additionalProperties": false + }, + {"type": "null"} + ] + }, + "member": { + "description": "The guild member object for the user", + "$ref": "#/components/schemas/GuildMemberResponse" + } + }, + "required": ["join_timestamp", "flags"], + "additionalProperties": false + }, + "ThreadMemberSettingsRequest": { + "type": "object", + "properties": { + "flags": { + "description": "Thread member notification flags (ALL_MESSAGES 1<<1, ONLY_MENTIONS 1<<2, NO_MESSAGES 1<<3)", + "$ref": "#/components/schemas/Int32Type" + }, + "muted": {"description": "Whether the thread is muted", "type": "boolean"}, + "mute_config": { + "description": "Thread mute configuration", + "anyOf": [ + { + "type": "object", + "properties": { + "end_time": { + "description": "When the mute expires", + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?$" + }, + {"type": "integer", "minimum": 0, "maximum": 8640000000000000} + ] + }, + {"type": "null"} + ] + }, + "selected_time_window": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Selected mute duration" + } + }, + "required": ["selected_time_window"] + }, + {"type": "null"} + ] + } + } + }, + "ThreadMemberListResponse": {"type": "array", "items": {"$ref": "#/components/schemas/ThreadMemberResponse"}}, + "ForumTagRequest": { + "type": "object", + "properties": { + "name": {"description": "The name of the tag (1-50 characters)", "type": "string"}, + "moderated": {"description": "Whether only moderators can add or remove this tag", "type": "boolean"}, + "emoji_id": { + "description": "The ID of a custom guild emoji", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}] + }, + "emoji_name": {"description": "The unicode character of the emoji", "type": ["string", "null"]} + }, + "required": ["name"], + "x-fluxer-experiment": "channel_threads" + }, "ChannelSlowmodeStateResponse": { "type": "object", "properties": { @@ -29100,6 +31102,34 @@ "additionalProperties": false }, "RtcRegionListResponse": {"type": "array", "items": {"$ref": "#/components/schemas/RtcRegionResponse"}}, + "ThreadPostDataRequest": { + "type": "object", + "properties": { + "thread_ids": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The IDs of the posts to get data for (max 100)" + } + }, + "required": ["thread_ids"], + "x-fluxer-experiment": "channel_threads" + }, + "ThreadPostDataResponse": { + "type": "object", + "properties": { + "threads": { + "type": "object", + "propertyNames": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "additionalProperties": {"$ref": "#/components/schemas/ThreadPostDataEntryResponse"}, + "description": "A mapping of post IDs to their post data" + } + }, + "required": ["threads"], + "additionalProperties": false, + "x-fluxer-experiment": "channel_threads" + }, "PermissionOverwriteCreateRequest": { "type": "object", "properties": { @@ -29118,6 +31148,181 @@ }, "required": ["type"] }, + "StartThreadFromMessageRequest": { + "type": "object", + "properties": { + "name": {"description": "The name of the thread (1-100 characters)", "type": "string"}, + "auto_archive_duration": {"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, + "rate_limit_per_user": { + "maximum": 21600, + "description": "Seconds a user has to wait before sending another message (0-21600)", + "$ref": "#/components/schemas/Int32Type" + }, + "location": {"description": "Accepted and ignored", "type": "string", "maxLength": 100} + }, + "required": ["name"] + }, + "ThreadChannelResponse": { + "type": "object", + "properties": { + "id": { + "description": "The unique identifier (snowflake) for this channel", + "$ref": "#/components/schemas/SnowflakeStringType" + }, + "guild_id": { + "description": "The ID of the guild this channel belongs to", + "$ref": "#/components/schemas/SnowflakeStringType" + }, + "name": {"description": "The name of the channel", "type": ["string", "null"]}, + "topic": {"description": "The topic of the channel", "type": ["string", "null"]}, + "url": { + "description": "The URL associated with the channel", + "anyOf": [{"type": "string", "format": "uri"}, {"type": "null"}] + }, + "icon": {"description": "The icon hash of the channel (for group DMs)", "type": ["string", "null"]}, + "owner_id": { + "description": "The ID of the owner of the channel (for group DMs)", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}] + }, + "type": {"$ref": "#/components/schemas/ChannelType"}, + "position": {"description": "The sorting position of the channel", "$ref": "#/components/schemas/Int32Type"}, + "parent_id": { + "description": "The ID of the parent category for this channel", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}] + }, + "bitrate": { + "description": "The bitrate of the voice channel in bits per second", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "user_limit": { + "description": "The maximum number of users allowed in the voice channel", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "voice_connection_limit": { + "description": "The maximum active voice connections allowed per user in the voice channel", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "rtc_region": {"description": "The voice region ID for the voice channel", "type": ["string", "null"]}, + "last_message_id": { + "description": "The ID of the last message sent in this channel", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}] + }, + "last_pin_timestamp": { + "description": "The ISO 8601 timestamp of when the last pinned message was pinned", + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + {"type": "null"} + ] + }, + "permission_overwrites": { + "description": "The permission overwrites for this channel", + "type": "array", + "items": {"$ref": "#/components/schemas/ChannelOverwriteResponse"} + }, + "recipients": { + "description": "The recipients of the DM channel", + "maxItems": 49, + "type": "array", + "items": {"$ref": "#/components/schemas/UserPartialResponse"} + }, + "nsfw": { + "description": "Whether the channel is marked as NSFW (effective value, walking channel → category → guild)", + "type": "boolean" + }, + "nsfw_override": { + "description": "Per-channel adult-content override; null means inherit from parent category and then guild. Categories use this same field as their own override.", + "type": ["boolean", "null"] + }, + "content_warning_level": { + "description": "Channel-level content warning override (0=inherit, 1=force-warn)", + "$ref": "#/components/schemas/ContentWarningLevel" + }, + "content_warning_text": { + "description": "Custom channel content warning text (max 200 characters); null inherits from parent or guild", + "anyOf": [{"type": "string", "maxLength": 200}, {"type": "null"}] + }, + "rate_limit_per_user": { + "description": "The slowmode rate limit in seconds", + "$ref": "#/components/schemas/Int32Type" + }, + "nicks": { + "description": "Custom nicknames for users in this channel (for group DMs)", + "type": "object", + "propertyNames": {"type": "string"}, + "additionalProperties": {"type": "string", "minLength": 1, "maxLength": 32} + }, + "flags": {"description": "Channel flags", "$ref": "#/components/schemas/Int32Type"}, + "thread_metadata": { + "description": "Thread-specific fields", + "$ref": "#/components/schemas/ThreadMetadataResponse" + }, + "applied_tags": { + "description": "IDs of the tags applied to a forum or media post", + "maxItems": 5, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeStringType"} + }, + "message_count": { + "description": "Messages in the thread, excluding the starter and deleted messages", + "$ref": "#/components/schemas/Int32Type" + }, + "total_message_sent": { + "description": "Messages ever sent in the thread, never decremented", + "$ref": "#/components/schemas/Int32Type" + }, + "member_count": { + "description": "Approximate number of thread members, capped at 50", + "$ref": "#/components/schemas/Int32Type" + }, + "member_ids_preview": { + "description": "IDs of the most recently joined members of a forum or media post, newest first", + "maxItems": 8, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeStringType"} + }, + "member": { + "description": "The thread member object for the current user", + "$ref": "#/components/schemas/ThreadMemberResponse" + }, + "default_auto_archive_duration": { + "description": "Default auto archive duration for new threads", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode copied onto new threads", + "$ref": "#/components/schemas/Int32Type" + }, + "available_tags": { + "description": "Tags that can be applied to posts", + "maxItems": 20, + "type": "array", + "items": {"$ref": "#/components/schemas/ForumTagResponse"} + }, + "default_reaction_emoji": { + "description": "Default reaction for posts", + "anyOf": [{"$ref": "#/components/schemas/DefaultReactionEmojiResponse"}, {"type": "null"}] + }, + "default_sort_order": { + "description": "Default sort order for posts", + "anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "default_forum_layout": { + "description": "Default layout for forum posts", + "$ref": "#/components/schemas/Int32Type" + }, + "default_tag_setting": { + "description": "Default tag search setting", + "type": "string", + "enum": ["match_some", "match_all"] + } + }, + "required": ["id", "type"], + "additionalProperties": false + }, "ReactionUsersPageResponse": { "type": "object", "properties": { @@ -29968,7 +32173,12 @@ "anyOf": [{"$ref": "#/components/schemas/ChannelNicknameOverrides"}, {"type": "null"}] } } - } + }, + {"$ref": "#/components/schemas/ChannelUpdatePublicThreadRequestBody"}, + {"$ref": "#/components/schemas/ChannelUpdatePrivateThreadRequestBody"}, + {"$ref": "#/components/schemas/ChannelUpdateThreadParentRequestBody"}, + {"$ref": "#/components/schemas/ChannelUpdateForumRequestBody"}, + {"$ref": "#/components/schemas/ChannelUpdateMediaRequestBody"} ] }, "BulkMessageFetchRequest": { @@ -31459,6 +33669,279 @@ "required": ["id_hash", "masked_ip", "current"], "additionalProperties": false }, + "ChannelUpdateMediaRequestBody": { + "type": "object", + "properties": { + "topic": {"description": "The guidelines of the channel (1-4096 characters)", "type": ["string", "null"]}, + "url": {"description": "External URL for link channels", "type": ["string", "null"]}, + "parent_id": { + "description": "ID of the parent category for this channel", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}] + }, + "bitrate": { + "description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature", + "anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000}, {"type": "null"}] + }, + "user_limit": { + "description": "Maximum users allowed in voice channel (0-99, 0 means unlimited)", + "anyOf": [{"type": "integer", "minimum": 0, "maximum": 99}, {"type": "null"}] + }, + "voice_connection_limit": { + "description": "Maximum active voice connections allowed per user in a voice channel (1-100)", + "anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}] + }, + "permission_overwrites": { + "description": "Permission overwrites for roles and members", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "The ID of the role or user to overwrite permissions for", + "$ref": "#/components/schemas/SnowflakeType" + }, + "type": { + "description": "The type of overwrite (0 = role, 1 = member)", + "x-enumNames": ["ROLE", "MEMBER"], + "enum": [0, 1], + "type": "integer" + }, + "allow": { + "description": "Bitwise value of allowed permissions", + "$ref": "#/components/schemas/UnsignedInt64Type" + }, + "deny": { + "description": "Bitwise value of denied permissions", + "$ref": "#/components/schemas/UnsignedInt64Type" + } + }, + "required": ["id", "type"] + } + }, + "rate_limit_per_user": { + "description": "Slowmode delay in seconds (0-21600)", + "anyOf": [{"type": "integer", "minimum": 0, "maximum": 21600}, {"type": "null"}] + }, + "nsfw": { + "description": "Legacy: setting true maps to nsfw_override=true; setting false maps to nsfw_override=null (inherit). Prefer nsfw_override.", + "type": ["boolean", "null"] + }, + "nsfw_override": { + "description": "Per-channel adult-content override (true=on, false=off, null=inherit from category then guild). Takes precedence over the legacy `nsfw` field if both are present.", + "type": ["boolean", "null"] + }, + "content_warning_level": { + "description": "Channel-level content warning override (0=inherit, 1=force-warn)", + "$ref": "#/components/schemas/ContentWarningLevelInput" + }, + "content_warning_text": { + "description": "Custom channel content warning text (max 200 characters); null inherits from parent or guild", + "anyOf": [{"type": "string", "maxLength": 200}, {"type": "null"}] + }, + "icon": { + "description": "Base64-encoded icon image for group DM channels", + "anyOf": [{"$ref": "#/components/schemas/Base64ImageType"}, {"type": "null"}] + }, + "owner_id": { + "description": "ID of the new owner for group DM channels", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}] + }, + "nicks": { + "description": "Custom nicknames for users in this channel", + "$ref": "#/components/schemas/ChannelNicknameOverrides" + }, + "rtc_region": { + "description": "Voice region ID for the voice channel (1-64 characters)", + "type": ["string", "null"] + }, + "name": {"description": "The name of the channel", "type": ["string", "null"]}, + "default_auto_archive_duration": { + "description": "Default auto archive duration in minutes for new threads", + "anyOf": [{"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode in seconds copied onto new threads (0-21600)", + "anyOf": [{"maximum": 21600, "$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "available_tags": { + "description": "Tags that can be applied to posts (max 20)", + "type": "array", + "items": {"$ref": "#/components/schemas/ForumTagUpdateRequest"} + }, + "default_reaction_emoji": { + "description": "Default reaction shown on posts", + "anyOf": [{"$ref": "#/components/schemas/DefaultReactionEmojiRequest"}, {"type": "null"}] + }, + "default_sort_order": {"anyOf": [{"$ref": "#/components/schemas/ForumSortOrderSchema"}, {"type": "null"}]}, + "default_tag_setting": {"anyOf": [{"$ref": "#/components/schemas/ForumTagSettingSchema"}, {"type": "null"}]}, + "flags": {"description": "Channel flags", "$ref": "#/components/schemas/Int32Type"} + }, + "x-fluxer-experiment": "channel_threads" + }, + "ChannelUpdateForumRequestBody": { + "type": "object", + "properties": { + "topic": {"description": "The guidelines of the channel (1-4096 characters)", "type": ["string", "null"]}, + "url": {"description": "External URL for link channels", "type": ["string", "null"]}, + "parent_id": { + "description": "ID of the parent category for this channel", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}] + }, + "bitrate": { + "description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature", + "anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000}, {"type": "null"}] + }, + "user_limit": { + "description": "Maximum users allowed in voice channel (0-99, 0 means unlimited)", + "anyOf": [{"type": "integer", "minimum": 0, "maximum": 99}, {"type": "null"}] + }, + "voice_connection_limit": { + "description": "Maximum active voice connections allowed per user in a voice channel (1-100)", + "anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}] + }, + "permission_overwrites": { + "description": "Permission overwrites for roles and members", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "The ID of the role or user to overwrite permissions for", + "$ref": "#/components/schemas/SnowflakeType" + }, + "type": { + "description": "The type of overwrite (0 = role, 1 = member)", + "x-enumNames": ["ROLE", "MEMBER"], + "enum": [0, 1], + "type": "integer" + }, + "allow": { + "description": "Bitwise value of allowed permissions", + "$ref": "#/components/schemas/UnsignedInt64Type" + }, + "deny": { + "description": "Bitwise value of denied permissions", + "$ref": "#/components/schemas/UnsignedInt64Type" + } + }, + "required": ["id", "type"] + } + }, + "rate_limit_per_user": { + "description": "Slowmode delay in seconds (0-21600)", + "anyOf": [{"type": "integer", "minimum": 0, "maximum": 21600}, {"type": "null"}] + }, + "nsfw": { + "description": "Legacy: setting true maps to nsfw_override=true; setting false maps to nsfw_override=null (inherit). Prefer nsfw_override.", + "type": ["boolean", "null"] + }, + "nsfw_override": { + "description": "Per-channel adult-content override (true=on, false=off, null=inherit from category then guild). Takes precedence over the legacy `nsfw` field if both are present.", + "type": ["boolean", "null"] + }, + "content_warning_level": { + "description": "Channel-level content warning override (0=inherit, 1=force-warn)", + "$ref": "#/components/schemas/ContentWarningLevelInput" + }, + "content_warning_text": { + "description": "Custom channel content warning text (max 200 characters); null inherits from parent or guild", + "anyOf": [{"type": "string", "maxLength": 200}, {"type": "null"}] + }, + "icon": { + "description": "Base64-encoded icon image for group DM channels", + "anyOf": [{"$ref": "#/components/schemas/Base64ImageType"}, {"type": "null"}] + }, + "owner_id": { + "description": "ID of the new owner for group DM channels", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}] + }, + "nicks": { + "description": "Custom nicknames for users in this channel", + "$ref": "#/components/schemas/ChannelNicknameOverrides" + }, + "rtc_region": { + "description": "Voice region ID for the voice channel (1-64 characters)", + "type": ["string", "null"] + }, + "name": {"description": "The name of the channel", "type": ["string", "null"]}, + "default_auto_archive_duration": { + "description": "Default auto archive duration in minutes for new threads", + "anyOf": [{"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode in seconds copied onto new threads (0-21600)", + "anyOf": [{"maximum": 21600, "$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "available_tags": { + "description": "Tags that can be applied to posts (max 20)", + "type": "array", + "items": {"$ref": "#/components/schemas/ForumTagUpdateRequest"} + }, + "default_reaction_emoji": { + "description": "Default reaction shown on posts", + "anyOf": [{"$ref": "#/components/schemas/DefaultReactionEmojiRequest"}, {"type": "null"}] + }, + "default_sort_order": {"anyOf": [{"$ref": "#/components/schemas/ForumSortOrderSchema"}, {"type": "null"}]}, + "default_tag_setting": {"anyOf": [{"$ref": "#/components/schemas/ForumTagSettingSchema"}, {"type": "null"}]}, + "flags": {"description": "Channel flags", "$ref": "#/components/schemas/Int32Type"}, + "default_forum_layout": {"anyOf": [{"$ref": "#/components/schemas/ForumLayoutSchema"}, {"type": "null"}]} + }, + "x-fluxer-experiment": "channel_threads" + }, + "ChannelUpdateThreadParentRequestBody": { + "type": "object", + "properties": { + "default_auto_archive_duration": { + "description": "Default auto archive duration in minutes for new threads", + "anyOf": [{"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode in seconds copied onto new threads (0-21600)", + "anyOf": [{"maximum": 21600, "$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + } + }, + "x-fluxer-experiment": "channel_threads" + }, + "ChannelUpdatePrivateThreadRequestBody": { + "type": "object", + "properties": { + "name": {"description": "The name of the thread (1-100 characters)", "type": "string"}, + "archived": {"description": "Whether the thread is archived", "type": ["boolean", "null"]}, + "auto_archive_duration": {"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, + "locked": {"description": "Whether only moderators can unarchive the thread", "type": ["boolean", "null"]}, + "rate_limit_per_user": { + "description": "Seconds a user has to wait before sending another message (0-21600)", + "anyOf": [{"maximum": 21600, "$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "flags": {"description": "Channel flags", "$ref": "#/components/schemas/Int32Type"}, + "invitable": { + "description": "Whether non-moderators can add other non-moderators", + "type": ["boolean", "null"] + } + }, + "x-fluxer-experiment": "channel_threads" + }, + "ChannelUpdatePublicThreadRequestBody": { + "type": "object", + "properties": { + "name": {"description": "The name of the thread (1-100 characters)", "type": "string"}, + "archived": {"description": "Whether the thread is archived", "type": ["boolean", "null"]}, + "auto_archive_duration": {"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, + "locked": {"description": "Whether only moderators can unarchive the thread", "type": ["boolean", "null"]}, + "rate_limit_per_user": { + "description": "Seconds a user has to wait before sending another message (0-21600)", + "anyOf": [{"maximum": 21600, "$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "flags": {"description": "Channel flags", "$ref": "#/components/schemas/Int32Type"}, + "applied_tags": { + "maxItems": 5, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "IDs of the tags applied to the post (max 5)" + } + }, + "x-fluxer-experiment": "channel_threads" + }, "ChannelNicknameOverrides": { "type": "object", "propertyNames": {"type": "string", "description": "User ID"}, @@ -31483,6 +33966,66 @@ "anyOf": [{"type": "string"}, {"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}], "format": "int64" }, + "ThreadAutoArchiveDurationSchema": { + "description": "Minutes of inactivity before the thread stops showing in the channel list", + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "format": "int32", + "x-enumNames": ["ONE_HOUR", "ONE_DAY", "THREE_DAYS", "ONE_WEEK"], + "x-enumDescriptions": ["One hour", "One day", "Three days", "One week"], + "enum": [60, 1440, 4320, 10080] + }, + "ForumLayoutSchema": { + "description": "The default layout of a forum channel", + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "format": "int32", + "x-enumNames": ["DEFAULT", "LIST", "GRID"], + "x-enumDescriptions": [ + "No default layout", + "Display posts as a list", + "Display posts as a collection of tiles" + ], + "enum": [0, 1, 2] + }, + "ForumSortOrderSchema": { + "description": "The default sort order of posts", + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "format": "int32", + "x-enumNames": ["LATEST_ACTIVITY", "CREATION_TIME"], + "x-enumDescriptions": ["Sort posts by latest activity", "Sort posts by creation time"], + "enum": [0, 1] + }, + "DefaultReactionEmojiRequest": { + "type": "object", + "properties": { + "emoji_id": { + "description": "The ID of a custom guild emoji", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}] + }, + "emoji_name": {"description": "The unicode character of the emoji", "type": ["string", "null"]} + }, + "x-fluxer-experiment": "channel_threads" + }, + "ForumTagUpdateRequest": { + "type": "object", + "properties": { + "name": {"description": "The name of the tag (1-50 characters)", "type": "string"}, + "moderated": {"description": "Whether only moderators can add or remove this tag", "type": "boolean"}, + "emoji_id": { + "description": "The ID of a custom guild emoji", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}] + }, + "emoji_name": {"description": "The unicode character of the emoji", "type": ["string", "null"]}, + "id": {"description": "The ID of an existing tag to keep", "$ref": "#/components/schemas/SnowflakeType"} + }, + "required": ["name"], + "x-fluxer-experiment": "channel_threads" + }, "PresignedAttachmentUploadResponseItem": { "oneOf": [ {"$ref": "#/components/schemas/SinglepartPresignedAttachmentUploadResponseItem"}, @@ -31970,7 +34513,7 @@ "ChannelType": { "description": "The type of the channel", "type": "integer", - "enum": [0, 1, 2, 3, 4, 5, 998, 999], + "enum": [0, 1, 2, 3, 4, 5, 10, 11, 12, 15, 16, 998, 999], "format": "int32", "x-enumNames": [ "GUILD_TEXT", @@ -31979,6 +34522,11 @@ "GROUP_DM", "GUILD_CATEGORY", "GUILD_ANNOUNCEMENT", + "ANNOUNCEMENT_THREAD", + "PUBLIC_THREAD", + "PRIVATE_THREAD", + "GUILD_FORUM", + "GUILD_MEDIA", "GUILD_LINK", "DM_PERSONAL_NOTES" ], @@ -31989,6 +34537,11 @@ "A group direct message between users", "A category that contains channels", "A guild channel whose messages can be published to channels that follow it", + "A public thread within an announcement channel", + "A public thread within a text, forum or media channel", + "A private thread within a text channel", + "A channel that can only contain threads", + "A channel that can only contain threads in a gallery view", "A link channel for external resources", "Personal notes DM channel" ] @@ -32404,7 +34957,7 @@ "$ref": "#/components/schemas/SnowflakeStringType" }, "message_id": { - "description": "The ID of the referenced message, absent on a channel follow system message", + "description": "The ID of the referenced message, absent on a channel follow system message and on thread created messages that reference only a thread", "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}] }, "guild_id": { @@ -32455,6 +35008,10 @@ }, {"type": "null"} ] + }, + "thread": { + "description": "The thread started from this message, when the viewer can see threads", + "$ref": "#/components/schemas/ThreadChannelResponse" } }, "required": [ @@ -32711,7 +35268,7 @@ "MessageType": { "description": "The type of message", "type": "integer", - "enum": [0, 1, 2, 3, 4, 5, 6, 7, 12, 19], + "enum": [0, 1, 2, 3, 4, 5, 6, 7, 12, 18, 19, 21], "format": "int32", "x-enumNames": [ "DEFAULT", @@ -32723,7 +35280,9 @@ "CHANNEL_PINNED_MESSAGE", "USER_JOIN", "CHANNEL_FOLLOW_ADD", - "REPLY" + "THREAD_CREATED", + "REPLY", + "THREAD_STARTER_MESSAGE" ], "x-enumDescriptions": [ "A regular message", @@ -32735,7 +35294,9 @@ "A system message indicating a message was pinned", "A system message indicating a user joined", "System message posted when a channel starts following an announcement channel", - "A reply message" + "A system message indicating a thread was created", + "A reply message", + "The first message of a thread, pointing at its source" ] }, "MessageEmbedChildResponse": { @@ -32970,6 +35531,159 @@ ], "additionalProperties": false }, + "DefaultReactionEmojiResponse": { + "type": "object", + "properties": { + "emoji_id": { + "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}], + "description": "The ID of a custom guild emoji" + }, + "emoji_name": {"description": "The unicode character of the emoji", "type": ["string", "null"]} + }, + "required": ["emoji_id", "emoji_name"], + "additionalProperties": false + }, + "ForumTagResponse": { + "type": "object", + "properties": { + "id": {"description": "The ID of the tag", "$ref": "#/components/schemas/SnowflakeStringType"}, + "name": {"type": "string", "maxLength": 50, "description": "The name of the tag"}, + "moderated": {"type": "boolean", "description": "Whether only moderators can add or remove this tag"}, + "emoji_id": { + "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}], + "description": "The ID of a custom guild emoji" + }, + "emoji_name": {"description": "The unicode character of the emoji", "type": ["string", "null"]} + }, + "required": ["id", "name", "moderated", "emoji_id", "emoji_name"], + "additionalProperties": false + }, + "ThreadMetadataResponse": { + "type": "object", + "properties": { + "archived": {"type": "boolean", "description": "Whether the thread is archived"}, + "auto_archive_duration": { + "description": "Minutes of inactivity before the thread stops showing in the channel list", + "$ref": "#/components/schemas/Int32Type" + }, + "archive_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When the archive status of the thread last changed" + }, + "locked": {"type": "boolean", "description": "Whether only moderators can interact with the thread"}, + "invitable": { + "description": "Whether non-moderators can add other non-moderators (private threads)", + "type": "boolean" + }, + "create_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When the thread was created" + } + }, + "required": ["archived", "auto_archive_duration", "archive_timestamp", "locked", "create_timestamp"], + "additionalProperties": false + }, + "ContentWarningLevel": { + "description": "The content warning level for a guild, category, or channel", + "type": "integer", + "enum": [0, 1], + "format": "int32", + "x-enumNames": ["INHERIT", "CONTENT_WARNING"], + "x-enumDescriptions": [ + "No level set on this scope; channels and categories inherit from parent", + "Show a content warning before entering" + ] + }, + "ChannelOverwriteResponse": { + "type": "object", + "properties": { + "id": { + "description": "The unique identifier for the role or user this overwrite applies to", + "$ref": "#/components/schemas/SnowflakeStringType" + }, + "type": {"$ref": "#/components/schemas/ChannelOverwriteType"}, + "allow": { + "description": "The bitwise value of allowed permissions", + "$ref": "#/components/schemas/Permissions" + }, + "deny": {"description": "The bitwise value of denied permissions", "$ref": "#/components/schemas/Permissions"} + }, + "required": ["id", "type", "allow", "deny"], + "additionalProperties": false + }, + "Permissions": { + "type": "string", + "pattern": "^\\d+$", + "description": "Permission bitfield as string", + "format": "int64", + "x-bitflagValues": [ + {"name": "CREATE_INSTANT_INVITE", "value": "1", "description": "Allows creation of instant invites"}, + {"name": "KICK_MEMBERS", "value": "2", "description": "Allows kicking members from the guild"}, + {"name": "BAN_MEMBERS", "value": "4", "description": "Allows banning members from the guild"}, + { + "name": "ADMINISTRATOR", + "value": "8", + "description": "Grants all permissions and bypasses channel permission overwrites" + }, + { + "name": "MANAGE_CHANNELS", + "value": "16", + "description": "Allows management, editing, and deletion of channels" + }, + {"name": "MANAGE_GUILD", "value": "32", "description": "Allows management and editing of the guild"}, + {"name": "ADD_REACTIONS", "value": "64", "description": "Allows adding reactions to messages"}, + {"name": "VIEW_AUDIT_LOG", "value": "128", "description": "Allows viewing of the audit log"}, + { + "name": "PRIORITY_SPEAKER", + "value": "256", + "description": "Allows using priority speaker in a voice channel" + }, + {"name": "STREAM", "value": "512", "description": "Allows the user to go live"}, + {"name": "VIEW_CHANNEL", "value": "1024", "description": "Allows viewing a channel"}, + {"name": "SEND_MESSAGES", "value": "2048", "description": "Allows sending messages in a channel"}, + {"name": "SEND_TTS_MESSAGES", "value": "4096", "description": "Allows sending text-to-speech messages"}, + {"name": "MANAGE_MESSAGES", "value": "8192", "description": "Allows for deleting and pinning messages"}, + {"name": "EMBED_LINKS", "value": "16384", "description": "Links sent will have an embed automatically"}, + {"name": "ATTACH_FILES", "value": "32768", "description": "Allows uploading files"}, + {"name": "READ_MESSAGE_HISTORY", "value": "65536", "description": "Allows reading message history"}, + {"name": "MENTION_EVERYONE", "value": "131072", "description": "Allows using @everyone and @here mentions"}, + {"name": "USE_EXTERNAL_EMOJIS", "value": "262144", "description": "Allows using emojis from other guilds"}, + {"name": "CONNECT", "value": "1048576", "description": "Allows connecting to a voice channel"}, + {"name": "SPEAK", "value": "2097152", "description": "Allows speaking in a voice channel"}, + {"name": "MUTE_MEMBERS", "value": "4194304", "description": "Allows muting members in voice channels"}, + {"name": "DEAFEN_MEMBERS", "value": "8388608", "description": "Allows deafening members in voice channels"}, + {"name": "MOVE_MEMBERS", "value": "16777216", "description": "Allows moving members between voice channels"}, + {"name": "USE_VAD", "value": "33554432", "description": "Allows using voice activity detection"}, + {"name": "CHANGE_NICKNAME", "value": "67108864", "description": "Allows changing own nickname"}, + {"name": "MANAGE_NICKNAMES", "value": "134217728", "description": "Allows changing other members nicknames"}, + {"name": "MANAGE_ROLES", "value": "268435456", "description": "Allows management and editing of roles"}, + {"name": "MANAGE_WEBHOOKS", "value": "536870912", "description": "Allows management and editing of webhooks"}, + { + "name": "MANAGE_EXPRESSIONS", + "value": "1073741824", + "description": "Allows management of guild expressions" + }, + { + "name": "USE_EXTERNAL_STICKERS", + "value": "137438953472", + "description": "Allows using stickers from other guilds" + }, + {"name": "MODERATE_MEMBERS", "value": "1099511627776", "description": "Allows timing out users"}, + {"name": "CREATE_EXPRESSIONS", "value": "8796093022208", "description": "Allows creating guild expressions"}, + {"name": "PIN_MESSAGES", "value": "2251799813685248", "description": "Allows pinning messages"}, + {"name": "BYPASS_SLOWMODE", "value": "4503599627370496", "description": "Allows bypassing slowmode"}, + {"name": "UPDATE_RTC_REGION", "value": "9007199254740992", "description": "Allows updating the voice region"}, + { + "name": "VIEW_CHANNEL_MEMBERS", + "value": "18014398509481984", + "description": "Allows viewing the member list in a channel" + } + ] + }, "ChannelOverwriteType": { "description": "The type of entity the overwrite applies to", "x-enumNames": ["ROLE", "MEMBER"], @@ -32977,6 +35691,21 @@ "enum": [0, 1], "type": "integer" }, + "ThreadPostDataEntryResponse": { + "type": "object", + "properties": { + "owner": { + "anyOf": [{"$ref": "#/components/schemas/GuildMemberResponse"}, {"type": "null"}], + "description": "The guild member who created the post" + }, + "first_message": { + "anyOf": [{"$ref": "#/components/schemas/MessageResponseSchema"}, {"type": "null"}], + "description": "The first message of the post" + } + }, + "required": ["owner", "first_message"], + "additionalProperties": false + }, "RtcRegionResponse": { "type": "object", "properties": { @@ -32987,6 +35716,160 @@ "required": ["id", "name", "emoji"], "additionalProperties": false }, + "StartForumThreadRequest": { + "type": "object", + "properties": { + "name": {"description": "The name of the post (1-100 characters)", "type": "string"}, + "type": { + "description": "The type of thread, which is always a public thread in a forum", + "$ref": "#/components/schemas/Int32Type" + }, + "auto_archive_duration": {"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, + "rate_limit_per_user": { + "description": "Seconds a user has to wait before sending another message (0-21600)", + "maximum": 21600, + "$ref": "#/components/schemas/Int32Type" + }, + "applied_tags": { + "maxItems": 5, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "IDs of the tags applied to the post (max 5)" + }, + "message": { + "properties": { + "content": {"anyOf": [{"$ref": "#/components/schemas/MessageContentRequest"}, {"type": "null"}]}, + "embeds": { + "type": "array", + "items": {"$ref": "#/components/schemas/RichEmbedRequest"}, + "description": "Array of embed objects to include in the message" + }, + "attachments": { + "type": "array", + "items": { + "anyOf": [ + {"$ref": "#/components/schemas/ClientUploadedAttachmentRequest"}, + {"$ref": "#/components/schemas/ClientAttachmentRequest"} + ] + }, + "description": "Array of attachment objects" + }, + "allowed_mentions": { + "description": "Controls which mentions trigger notifications", + "anyOf": [{"$ref": "#/components/schemas/AllowedMentionsRequest"}, {"type": "null"}] + }, + "flags": { + "default": 0, + "$ref": "#/components/schemas/MessageFlags", + "description": "Message flags bitfield" + }, + "sticker_ids": { + "description": "Array of sticker IDs to include (max 3)", + "anyOf": [ + {"maxItems": 3, "type": "array", "items": {"$ref": "#/components/schemas/SnowflakeType"}}, + {"type": "null"} + ] + } + }, + "description": "The first message of the post", + "$ref": "#/components/schemas/ForumThreadMessageRequest" + }, + "location": {"description": "Accepted and ignored", "type": "string", "maxLength": 100} + }, + "required": ["name", "message"], + "x-fluxer-experiment": "channel_threads" + }, + "StartThreadRequest": { + "type": "object", + "properties": { + "name": {"description": "The name of the thread (1-100 characters)", "type": "string"}, + "type": {"$ref": "#/components/schemas/ThreadChannelType"}, + "auto_archive_duration": {"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, + "rate_limit_per_user": { + "maximum": 21600, + "description": "Seconds a user has to wait before sending another message (0-21600)", + "$ref": "#/components/schemas/Int32Type" + }, + "invitable": { + "description": "Whether non-moderators can add other non-moderators (private threads)", + "type": "boolean" + }, + "location": {"description": "Accepted and ignored", "type": "string", "maxLength": 100} + }, + "required": ["name", "type"] + }, + "ThreadChannelType": { + "description": "The type of thread to create", + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "format": "int32", + "x-enumNames": ["ANNOUNCEMENT_THREAD", "PUBLIC_THREAD", "PRIVATE_THREAD"], + "x-enumDescriptions": ["A thread in an announcement channel", "A public thread", "A private thread"], + "enum": [10, 11, 12] + }, + "ForumThreadMessageRequest": { + "type": "object", + "properties": { + "content": {"anyOf": [{"$ref": "#/components/schemas/MessageContentRequest"}, {"type": "null"}]}, + "embeds": { + "type": "array", + "items": {"$ref": "#/components/schemas/RichEmbedRequest"}, + "description": "Array of embed objects to include in the message" + }, + "attachments": { + "type": "array", + "items": { + "anyOf": [ + {"$ref": "#/components/schemas/ClientUploadedAttachmentRequest"}, + {"$ref": "#/components/schemas/ClientAttachmentRequest"} + ] + }, + "description": "Array of attachment objects" + }, + "allowed_mentions": { + "description": "Controls which mentions trigger notifications", + "anyOf": [{"$ref": "#/components/schemas/AllowedMentionsRequest"}, {"type": "null"}] + }, + "flags": {"default": 0, "$ref": "#/components/schemas/MessageFlags", "description": "Message flags bitfield"}, + "sticker_ids": { + "description": "Array of sticker IDs to include (max 3)", + "anyOf": [ + {"maxItems": 3, "type": "array", "items": {"$ref": "#/components/schemas/SnowflakeType"}}, + {"type": "null"} + ] + } + }, + "x-fluxer-experiment": "channel_threads" + }, + "ThreadSearchResponse": { + "type": "object", + "properties": { + "threads": { + "type": "array", + "items": {"$ref": "#/components/schemas/ThreadChannelResponse"}, + "description": "The threads that match the search" + }, + "members": { + "type": "array", + "items": {"$ref": "#/components/schemas/ThreadMemberResponse"}, + "description": "A thread member object for each returned thread the user joined" + }, + "has_more": {"type": "boolean", "description": "Whether more threads could be returned by a later request"}, + "total_results": { + "description": "The total number of threads that match the search", + "$ref": "#/components/schemas/Int32Type" + }, + "first_messages": { + "description": "The first message of each returned post, in forum and media channels only", + "type": "array", + "items": {"$ref": "#/components/schemas/MessageResponseSchema"} + } + }, + "required": ["threads", "members", "has_more", "total_results"], + "additionalProperties": false, + "x-fluxer-experiment": "channel_threads" + }, "WebhookType": { "description": "The type of webhook", "type": "integer", @@ -33011,6 +35894,15 @@ "required": ["enabled"], "additionalProperties": false }, + "ChannelThreadsAssignmentResponse": { + "type": "object", + "properties": { + "active": {"type": "boolean", "const": true}, + "config_version": {"type": "integer", "minimum": 0, "maximum": 9007199254740991} + }, + "required": ["active", "config_version"], + "additionalProperties": false + }, "DomainMigrationAssignmentResponse": { "type": "object", "properties": {"enabled": {"type": "boolean"}}, @@ -33371,7 +36263,7 @@ "type": "integer", "enum": [ 1, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 40, 41, 42, 50, 51, 52, 60, 61, 62, - 90, 91, 92, 72, 73, 74, 75 + 90, 91, 92, 72, 73, 74, 75, 110, 111, 112 ], "format": "int32", "x-enumNames": [ @@ -33409,7 +36301,10 @@ "MESSAGE_DELETE", "MESSAGE_BULK_DELETE", "MESSAGE_PIN", - "MESSAGE_UNPIN" + "MESSAGE_UNPIN", + "THREAD_CREATE", + "THREAD_UPDATE", + "THREAD_DELETE" ], "x-enumDescriptions": [ "Guild settings were updated", @@ -33446,7 +36341,10 @@ "Message was deleted", "Messages were bulk deleted", "Message was pinned", - "Message was unpinned" + "Message was unpinned", + "Thread was created", + "Thread was updated", + "Thread was deleted" ] }, "GuildBanResponse": { @@ -33479,6 +36377,201 @@ "required": ["user", "moderator_id", "banned_at"], "additionalProperties": false }, + "GuildMediaChannelCreateRequest": { + "type": "object", + "properties": { + "topic": {"description": "The guidelines of the channel (1-4096 characters)", "type": ["string", "null"]}, + "url": {"description": "External URL for link channels", "type": ["string", "null"]}, + "parent_id": { + "description": "ID of the parent category for this channel", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}] + }, + "bitrate": { + "description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature", + "anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000}, {"type": "null"}] + }, + "user_limit": { + "description": "Maximum users allowed in voice channel (0-99, 0 means unlimited)", + "anyOf": [{"type": "integer", "minimum": 0, "maximum": 99}, {"type": "null"}] + }, + "voice_connection_limit": { + "description": "Maximum active voice connections allowed per user in a voice channel (1-100)", + "anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}] + }, + "permission_overwrites": { + "description": "Permission overwrites for roles and members", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "The ID of the role or user to overwrite permissions for", + "$ref": "#/components/schemas/SnowflakeType" + }, + "type": { + "description": "The type of overwrite (0 = role, 1 = member)", + "x-enumNames": ["ROLE", "MEMBER"], + "enum": [0, 1], + "type": "integer" + }, + "allow": { + "description": "Bitwise value of allowed permissions", + "$ref": "#/components/schemas/UnsignedInt64Type" + }, + "deny": { + "description": "Bitwise value of denied permissions", + "$ref": "#/components/schemas/UnsignedInt64Type" + } + }, + "required": ["id", "type"] + } + }, + "rate_limit_per_user": { + "description": "Slowmode delay in seconds (0-21600)", + "anyOf": [{"type": "integer", "minimum": 0, "maximum": 21600}, {"type": "null"}] + }, + "nsfw": {"default": false, "description": "Whether the channel is marked as NSFW", "type": "boolean"}, + "nsfw_override": { + "description": "Per-channel adult-content override (true=on, false=off, null=inherit from category then guild). Takes precedence over the legacy `nsfw` field if both are present.", + "type": ["boolean", "null"] + }, + "content_warning_level": { + "description": "Channel-level content warning override (0=inherit, 1=force-warn)", + "$ref": "#/components/schemas/ContentWarningLevelInput" + }, + "content_warning_text": { + "description": "Custom channel content warning text (max 200 characters); null inherits from parent or guild", + "anyOf": [{"type": "string", "maxLength": 200}, {"type": "null"}] + }, + "type": { + "type": "integer", + "x-enumNames": ["GUILD_MEDIA"], + "x-enumDescriptions": ["Channel type (media channel)"], + "enum": [16] + }, + "name": {"description": "The name of the channel", "type": "string"}, + "default_auto_archive_duration": { + "description": "Default auto archive duration in minutes for new threads", + "anyOf": [{"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode in seconds copied onto new threads (0-21600)", + "anyOf": [{"maximum": 21600, "$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "available_tags": { + "description": "Tags that can be applied to posts (max 20)", + "type": "array", + "items": {"$ref": "#/components/schemas/ForumTagUpdateRequest"} + }, + "default_reaction_emoji": { + "description": "Default reaction shown on posts", + "anyOf": [{"$ref": "#/components/schemas/DefaultReactionEmojiRequest"}, {"type": "null"}] + }, + "default_sort_order": {"anyOf": [{"$ref": "#/components/schemas/ForumSortOrderSchema"}, {"type": "null"}]}, + "default_tag_setting": {"anyOf": [{"$ref": "#/components/schemas/ForumTagSettingSchema"}, {"type": "null"}]}, + "flags": {"description": "Channel flags", "$ref": "#/components/schemas/Int32Type"} + }, + "required": ["type", "name"], + "x-fluxer-experiment": "channel_threads" + }, + "GuildForumChannelCreateRequest": { + "type": "object", + "properties": { + "topic": {"description": "The guidelines of the channel (1-4096 characters)", "type": ["string", "null"]}, + "url": {"description": "External URL for link channels", "type": ["string", "null"]}, + "parent_id": { + "description": "ID of the parent category for this channel", + "anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}] + }, + "bitrate": { + "description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature", + "anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000}, {"type": "null"}] + }, + "user_limit": { + "description": "Maximum users allowed in voice channel (0-99, 0 means unlimited)", + "anyOf": [{"type": "integer", "minimum": 0, "maximum": 99}, {"type": "null"}] + }, + "voice_connection_limit": { + "description": "Maximum active voice connections allowed per user in a voice channel (1-100)", + "anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}] + }, + "permission_overwrites": { + "description": "Permission overwrites for roles and members", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "The ID of the role or user to overwrite permissions for", + "$ref": "#/components/schemas/SnowflakeType" + }, + "type": { + "description": "The type of overwrite (0 = role, 1 = member)", + "x-enumNames": ["ROLE", "MEMBER"], + "enum": [0, 1], + "type": "integer" + }, + "allow": { + "description": "Bitwise value of allowed permissions", + "$ref": "#/components/schemas/UnsignedInt64Type" + }, + "deny": { + "description": "Bitwise value of denied permissions", + "$ref": "#/components/schemas/UnsignedInt64Type" + } + }, + "required": ["id", "type"] + } + }, + "rate_limit_per_user": { + "description": "Slowmode delay in seconds (0-21600)", + "anyOf": [{"type": "integer", "minimum": 0, "maximum": 21600}, {"type": "null"}] + }, + "nsfw": {"default": false, "description": "Whether the channel is marked as NSFW", "type": "boolean"}, + "nsfw_override": { + "description": "Per-channel adult-content override (true=on, false=off, null=inherit from category then guild). Takes precedence over the legacy `nsfw` field if both are present.", + "type": ["boolean", "null"] + }, + "content_warning_level": { + "description": "Channel-level content warning override (0=inherit, 1=force-warn)", + "$ref": "#/components/schemas/ContentWarningLevelInput" + }, + "content_warning_text": { + "description": "Custom channel content warning text (max 200 characters); null inherits from parent or guild", + "anyOf": [{"type": "string", "maxLength": 200}, {"type": "null"}] + }, + "type": { + "type": "integer", + "x-enumNames": ["GUILD_FORUM"], + "x-enumDescriptions": ["Channel type (forum channel)"], + "enum": [15] + }, + "name": {"description": "The name of the channel", "type": "string"}, + "default_auto_archive_duration": { + "description": "Default auto archive duration in minutes for new threads", + "anyOf": [{"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode in seconds copied onto new threads (0-21600)", + "anyOf": [{"maximum": 21600, "$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + }, + "available_tags": { + "description": "Tags that can be applied to posts (max 20)", + "type": "array", + "items": {"$ref": "#/components/schemas/ForumTagUpdateRequest"} + }, + "default_reaction_emoji": { + "description": "Default reaction shown on posts", + "anyOf": [{"$ref": "#/components/schemas/DefaultReactionEmojiRequest"}, {"type": "null"}] + }, + "default_sort_order": {"anyOf": [{"$ref": "#/components/schemas/ForumSortOrderSchema"}, {"type": "null"}]}, + "default_tag_setting": {"anyOf": [{"$ref": "#/components/schemas/ForumTagSettingSchema"}, {"type": "null"}]}, + "flags": {"description": "Channel flags", "$ref": "#/components/schemas/Int32Type"}, + "default_forum_layout": {"anyOf": [{"$ref": "#/components/schemas/ForumLayoutSchema"}, {"type": "null"}]} + }, + "required": ["type", "name"], + "x-fluxer-experiment": "channel_threads" + }, "GuildLinkChannelCreateRequest": { "type": "object", "properties": { @@ -33779,7 +36872,15 @@ "x-enumDescriptions": ["Channel type (announcement channel)"], "enum": [5] }, - "name": {"description": "The name of the channel", "type": "string"} + "name": {"description": "The name of the channel", "type": "string"}, + "default_auto_archive_duration": { + "description": "Default auto archive duration in minutes for new threads", + "anyOf": [{"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode in seconds copied onto new threads (0-21600)", + "anyOf": [{"maximum": 21600, "$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + } }, "required": ["type", "name"] }, @@ -33855,7 +36956,15 @@ "x-enumDescriptions": ["Channel type (text channel)"], "enum": [0] }, - "name": {"description": "The name of the channel", "type": "string"} + "name": {"description": "The name of the channel", "type": "string"}, + "default_auto_archive_duration": { + "description": "Default auto archive duration in minutes for new threads", + "anyOf": [{"$ref": "#/components/schemas/ThreadAutoArchiveDurationSchema"}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode in seconds copied onto new threads (0-21600)", + "anyOf": [{"maximum": 21600, "$ref": "#/components/schemas/Int32Type"}, {"type": "null"}] + } }, "required": ["type", "name"] }, @@ -33988,75 +37097,6 @@ {"name": "BANNER_UNSET", "value": "2", "description": "Guild member banner is unset"} ] }, - "Permissions": { - "type": "string", - "pattern": "^\\d+$", - "description": "Permission bitfield as string", - "format": "int64", - "x-bitflagValues": [ - {"name": "CREATE_INSTANT_INVITE", "value": "1", "description": "Allows creation of instant invites"}, - {"name": "KICK_MEMBERS", "value": "2", "description": "Allows kicking members from the guild"}, - {"name": "BAN_MEMBERS", "value": "4", "description": "Allows banning members from the guild"}, - { - "name": "ADMINISTRATOR", - "value": "8", - "description": "Grants all permissions and bypasses channel permission overwrites" - }, - { - "name": "MANAGE_CHANNELS", - "value": "16", - "description": "Allows management, editing, and deletion of channels" - }, - {"name": "MANAGE_GUILD", "value": "32", "description": "Allows management and editing of the guild"}, - {"name": "ADD_REACTIONS", "value": "64", "description": "Allows adding reactions to messages"}, - {"name": "VIEW_AUDIT_LOG", "value": "128", "description": "Allows viewing of the audit log"}, - { - "name": "PRIORITY_SPEAKER", - "value": "256", - "description": "Allows using priority speaker in a voice channel" - }, - {"name": "STREAM", "value": "512", "description": "Allows the user to go live"}, - {"name": "VIEW_CHANNEL", "value": "1024", "description": "Allows viewing a channel"}, - {"name": "SEND_MESSAGES", "value": "2048", "description": "Allows sending messages in a channel"}, - {"name": "SEND_TTS_MESSAGES", "value": "4096", "description": "Allows sending text-to-speech messages"}, - {"name": "MANAGE_MESSAGES", "value": "8192", "description": "Allows for deleting and pinning messages"}, - {"name": "EMBED_LINKS", "value": "16384", "description": "Links sent will have an embed automatically"}, - {"name": "ATTACH_FILES", "value": "32768", "description": "Allows uploading files"}, - {"name": "READ_MESSAGE_HISTORY", "value": "65536", "description": "Allows reading message history"}, - {"name": "MENTION_EVERYONE", "value": "131072", "description": "Allows using @everyone and @here mentions"}, - {"name": "USE_EXTERNAL_EMOJIS", "value": "262144", "description": "Allows using emojis from other guilds"}, - {"name": "CONNECT", "value": "1048576", "description": "Allows connecting to a voice channel"}, - {"name": "SPEAK", "value": "2097152", "description": "Allows speaking in a voice channel"}, - {"name": "MUTE_MEMBERS", "value": "4194304", "description": "Allows muting members in voice channels"}, - {"name": "DEAFEN_MEMBERS", "value": "8388608", "description": "Allows deafening members in voice channels"}, - {"name": "MOVE_MEMBERS", "value": "16777216", "description": "Allows moving members between voice channels"}, - {"name": "USE_VAD", "value": "33554432", "description": "Allows using voice activity detection"}, - {"name": "CHANGE_NICKNAME", "value": "67108864", "description": "Allows changing own nickname"}, - {"name": "MANAGE_NICKNAMES", "value": "134217728", "description": "Allows changing other members nicknames"}, - {"name": "MANAGE_ROLES", "value": "268435456", "description": "Allows management and editing of roles"}, - {"name": "MANAGE_WEBHOOKS", "value": "536870912", "description": "Allows management and editing of webhooks"}, - { - "name": "MANAGE_EXPRESSIONS", - "value": "1073741824", - "description": "Allows management of guild expressions" - }, - { - "name": "USE_EXTERNAL_STICKERS", - "value": "137438953472", - "description": "Allows using stickers from other guilds" - }, - {"name": "MODERATE_MEMBERS", "value": "1099511627776", "description": "Allows timing out users"}, - {"name": "CREATE_EXPRESSIONS", "value": "8796093022208", "description": "Allows creating guild expressions"}, - {"name": "PIN_MESSAGES", "value": "2251799813685248", "description": "Allows pinning messages"}, - {"name": "BYPASS_SLOWMODE", "value": "4503599627370496", "description": "Allows bypassing slowmode"}, - {"name": "UPDATE_RTC_REGION", "value": "9007199254740992", "description": "Allows updating the voice region"}, - { - "name": "VIEW_CHANNEL_MEMBERS", - "value": "18014398509481984", - "description": "Allows viewing the member list in a channel" - } - ] - }, "GuildStickerWithUserResponse": { "type": "object", "properties": { @@ -34121,17 +37161,6 @@ "Media content from all members will be scanned" ] }, - "ContentWarningLevel": { - "description": "The content warning level for a guild, category, or channel", - "type": "integer", - "enum": [0, 1], - "format": "int32", - "x-enumNames": ["INHERIT", "CONTENT_WARNING"], - "x-enumDescriptions": [ - "No level set on this scope; channels and categories inherit from parent", - "Show a content warning before entering" - ] - }, "GuildMFALevel": { "description": "Required MFA level for moderation actions", "type": "integer", @@ -34887,6 +37916,10 @@ "version": { "description": "Read-state version for ordering updates as a decimal uint64", "$ref": "#/components/schemas/UnsignedInt64StringType" + }, + "flags": { + "description": "Server-stamped read state flags, present only on thread and forum read states", + "$ref": "#/components/schemas/Int32Type" } }, "required": ["id", "mention_count", "last_message_id", "last_pin_timestamp"], @@ -35483,7 +38516,7 @@ "$ref": "#/components/schemas/SnowflakeStringType" }, "message_id": { - "description": "The ID of the referenced message, absent on a channel follow system message", + "description": "The ID of the referenced message, absent on a channel follow system message and on thread created messages that reference only a thread", "anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}] }, "guild_id": { @@ -35537,6 +38570,10 @@ }, {"type": "null"} ] + }, + "thread": { + "description": "The thread started from this message, when the viewer can see threads", + "$ref": "#/components/schemas/ThreadChannelResponse" } }, "required": [ @@ -35575,6 +38612,16 @@ "description": "Opaque cursor for fetching the next page of results", "type": "array", "items": {"type": "string"} + }, + "threads": { + "description": "The threads that contain the returned messages, when the viewer can see threads", + "type": "array", + "items": {"$ref": "#/components/schemas/ThreadChannelResponse"} + }, + "members": { + "description": "A thread member object for each returned thread the current user has joined", + "type": "array", + "items": {"$ref": "#/components/schemas/ThreadMemberResponse"} } }, "required": ["messages", "channels", "total", "hits_per_page", "page"], @@ -35785,23 +38832,6 @@ } ] }, - "ChannelOverwriteResponse": { - "type": "object", - "properties": { - "id": { - "description": "The unique identifier for the role or user this overwrite applies to", - "$ref": "#/components/schemas/SnowflakeStringType" - }, - "type": {"$ref": "#/components/schemas/ChannelOverwriteType"}, - "allow": { - "description": "The bitwise value of allowed permissions", - "$ref": "#/components/schemas/Permissions" - }, - "deny": {"description": "The bitwise value of denied permissions", "$ref": "#/components/schemas/Permissions"} - }, - "required": ["id", "type", "allow", "deny"], - "additionalProperties": false - }, "ConnectionVisibilityFlags": { "type": "integer", "minimum": 0, diff --git a/fluxer_api/src/api/rate_limit_configs/ChannelRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/ChannelRateLimitConfig.ts index 0e87cb09e..794391d75 100644 --- a/fluxer_api/src/api/rate_limit_configs/ChannelRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/ChannelRateLimitConfig.ts @@ -136,6 +136,46 @@ export const ChannelRateLimitConfigs = { bucket: 'channel:follower_stats::channel_id', config: {limit: 10, windowMs: ms('10 seconds')}, } as RouteRateLimitConfig, + CHANNEL_THREAD_CREATE: { + bucket: 'channel:thread:create::channel_id', + config: {limit: 10, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + CHANNEL_THREAD_MEMBER_PUT: { + bucket: 'channel:thread:member:put::channel_id', + config: {limit: 10, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + CHANNEL_THREAD_MEMBER_DELETE: { + bucket: 'channel:thread:member:delete::channel_id', + config: {limit: 10, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + CHANNEL_THREAD_MEMBER_SETTINGS: { + bucket: 'channel:thread:member:settings::channel_id', + config: {limit: 10, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + CHANNEL_THREAD_MEMBERS_LIST: { + bucket: 'channel:thread:members:list::channel_id', + config: {limit: 20, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + CHANNEL_THREAD_MEMBER_GET: { + bucket: 'channel:thread:member:get::channel_id', + config: {limit: 40, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + CHANNEL_THREADS_ARCHIVED_LIST: { + bucket: 'channel:threads:archived:list::channel_id', + config: {limit: 20, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + CHANNEL_THREADS_SEARCH: { + bucket: 'channel:threads:search::channel_id', + config: {limit: 20, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + CHANNEL_POST_DATA: { + bucket: 'channel:post_data::channel_id', + config: {limit: 20, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + CHANNEL_FORUM_TAGS: { + bucket: 'channel:forum_tags::channel_id', + config: {limit: 10, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, VOICE_ENTRANCE_SOUND_PLAY: { bucket: 'voice:entrance_sound:play::user_id::channel_id', config: {limit: 3, windowMs: ms('30 seconds')}, diff --git a/fluxer_api/src/api/rate_limit_configs/GuildRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/GuildRateLimitConfig.ts index 32f72b0c0..20692d616 100644 --- a/fluxer_api/src/api/rate_limit_configs/GuildRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/GuildRateLimitConfig.ts @@ -60,6 +60,10 @@ export const GuildRateLimitConfigs = { bucket: 'guild:channels:list::guild_id', config: {limit: 60, windowMs: ms('10 seconds')}, } as RouteRateLimitConfig, + GUILD_THREADS_ACTIVE: { + bucket: 'guild:threads:active::guild_id', + config: {limit: 5, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, GUILD_CHANNEL_CREATE: { bucket: 'guild:channel:create::guild_id', config: {limit: 10, windowMs: ms('1 minute')}, diff --git a/fluxer_api/src/api/read_state/IReadStateRepository.ts b/fluxer_api/src/api/read_state/IReadStateRepository.ts index b233a74b5..ca76471db 100644 --- a/fluxer_api/src/api/read_state/IReadStateRepository.ts +++ b/fluxer_api/src/api/read_state/IReadStateRepository.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; +import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; import type {ReadState} from '@app/api/models/ReadState'; export interface ReadStateUpsert { @@ -8,6 +8,18 @@ export interface ReadStateUpsert { previous: ReadState | null; } +export interface ReadStateMarker { + flags: number; + guildId: GuildID; +} + +export interface ReadStateMentionUpdate { + userId: UserID; + channelId: ChannelID; + messageId: MessageID; + marker?: ReadStateMarker | null; +} + export abstract class IReadStateRepository { abstract listReadStates(userId: UserID): Promise>; @@ -20,6 +32,7 @@ export abstract class IReadStateRepository { mentionCount?: number, lastPinTimestamp?: Date, manual?: boolean, + marker?: ReadStateMarker | null, ): Promise; abstract incrementReadStateMentions( @@ -27,15 +40,10 @@ export abstract class IReadStateRepository { channelId: ChannelID, messageId: MessageID, incrementBy?: number, + marker?: ReadStateMarker | null, ): Promise; - abstract bulkIncrementMentionCounts( - updates: Array<{ - userId: UserID; - channelId: ChannelID; - messageId: MessageID; - }>, - ): Promise< + abstract bulkIncrementMentionCounts(updates: Array): Promise< Array<{ userId: UserID; channelId: ChannelID; @@ -47,8 +55,14 @@ export abstract class IReadStateRepository { readStates: Array<{ channelId: ChannelID; messageId: MessageID; + marker?: ReadStateMarker | null; }>, ): Promise>; - abstract upsertPinAck(userId: UserID, channelId: ChannelID, lastPinTimestamp: Date): Promise; + abstract upsertPinAck( + userId: UserID, + channelId: ChannelID, + lastPinTimestamp: Date, + marker?: ReadStateMarker | null, + ): Promise; } diff --git a/fluxer_api/src/api/read_state/ReadStateChannelMeta.ts b/fluxer_api/src/api/read_state/ReadStateChannelMeta.ts new file mode 100644 index 000000000..db658e80b --- /dev/null +++ b/fluxer_api/src/api/read_state/ReadStateChannelMeta.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; +import {fetchOne} from '@app/api/database/CassandraQueryExecution'; +import type {ChannelRow} from '@app/api/database/types/ChannelTypes'; +import { + THREAD_CHANNEL_TYPES, + THREAD_FEATURE_CHANNEL_TYPES, + userActive, + viewerActive, + viewerFromCtx, +} from '@app/api/experiment/ChannelThreadsGate'; +import type {ReadStateMarker} from '@app/api/read_state/IReadStateRepository'; +import {Channels} from '@app/api/Tables'; +import type {HonoEnv} from '@app/api/types/HonoEnv'; +import {ReadStateFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {Context} from 'hono'; + +export interface ReadStateChannelHint { + type: number; + guildId: GuildID | null; +} + +const CHANNEL_META_CACHE_MAX_ENTRIES = 50_000; + +const FETCH_CHANNEL_META_CQL = Channels.selectCql({ + columns: ['channel_id', 'guild_id', 'type'], + where: [Channels.where.eq('channel_id'), Channels.where.eq('soft_deleted')], + limit: 1, +}); + +const channelMetaCache = new Map(); + +function rememberChannelMeta(channelId: ChannelID, meta: ReadStateChannelHint): void { + const key = channelId.toString(); + channelMetaCache.delete(key); + channelMetaCache.set(key, meta); + if (channelMetaCache.size > CHANNEL_META_CACHE_MAX_ENTRIES) { + const oldest = channelMetaCache.keys().next().value; + if (oldest !== undefined) channelMetaCache.delete(oldest); + } +} + +async function loadChannelMeta(channelId: ChannelID): Promise { + const cached = channelMetaCache.get(channelId.toString()); + if (cached !== undefined) return cached; + const row = await fetchOne>(FETCH_CHANNEL_META_CQL, { + channel_id: channelId, + soft_deleted: false, + }); + if (!row) return null; + const meta = {type: row.type, guildId: row.guild_id ?? null}; + rememberChannelMeta(channelId, meta); + return meta; +} + +export function readStateCapable(ctx: Context): boolean { + const viewer = viewerFromCtx(ctx); + return viewer.kind === 'user' && viewer.capable; +} + +export function readStateMarkerFor(channel: ReadStateChannelHint): ReadStateMarker | null { + if (channel.guildId === null || !THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) return null; + const flags = + ReadStateFlags.IS_GUILD_CHANNEL | (THREAD_CHANNEL_TYPES.has(channel.type) ? ReadStateFlags.IS_THREAD : 0); + return {flags, guildId: channel.guildId}; +} + +export async function resolveReadStateMarker(params: { + userId: UserID; + channelId: ChannelID; + capable: boolean; + channel?: ReadStateChannelHint | null; +}): Promise { + const {userId, channelId, capable} = params; + if (!capable || !userActive(userId)) return null; + const channel = params.channel ?? (await loadChannelMeta(channelId)); + if (!channel) return null; + const marker = readStateMarkerFor(channel); + if (!marker) return null; + return viewerActive({kind: 'user', userId, bot: false, capable}, marker.guildId) ? marker : null; +} + +export function clearReadStateChannelMetaCacheForTesting(): void { + channelMetaCache.clear(); +} diff --git a/fluxer_api/src/api/read_state/ReadStateController.ts b/fluxer_api/src/api/read_state/ReadStateController.ts index c9c526d39..1cc5938f9 100644 --- a/fluxer_api/src/api/read_state/ReadStateController.ts +++ b/fluxer_api/src/api/read_state/ReadStateController.ts @@ -4,6 +4,7 @@ import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '@app/api/RateLimitConfig'; +import {readStateCapable} from '@app/api/read_state/ReadStateChannelMeta'; import type {HonoEnv} from '@app/api/types/HonoEnv'; import {Validator} from '@app/api/Validator'; import { @@ -35,6 +36,7 @@ export function ReadStateController(app: Hono): void { await ctx.get('readStateRequestService').ackReadStates({ userId: ctx.get('user').id, data: ctx.req.valid('json'), + capable: readStateCapable(ctx), }), ); }, @@ -58,6 +60,7 @@ export function ReadStateController(app: Hono): void { await ctx.get('readStateRequestService').bulkAckMessages({ userId: ctx.get('user').id, data: ctx.req.valid('json'), + capable: readStateCapable(ctx), }); return ctx.body(null, 204); }, diff --git a/fluxer_api/src/api/read_state/ReadStateRepository.test.ts b/fluxer_api/src/api/read_state/ReadStateRepository.test.ts index f98594f7d..b174582cf 100644 --- a/fluxer_api/src/api/read_state/ReadStateRepository.test.ts +++ b/fluxer_api/src/api/read_state/ReadStateRepository.test.ts @@ -3,19 +3,13 @@ import {type ChannelID, createChannelID, createMessageID, createUserID, type UserID} from '@app/api/BrandedTypes'; import {getKvMeta} from '@app/api/database/CassandraMetaRegistry'; import {fetchOne, setCassandraQueryExecutorForTesting, upsertOne} from '@app/api/database/CassandraQueryExecution'; -import {defineTable} from '@app/api/database/CassandraTableDsl'; import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes'; import type {ReadStateRow} from '@app/api/database/types/ChannelTypes'; -import {READ_STATE_COLUMNS} from '@app/api/database/types/ChannelTypes'; import {ReadStateRepository} from '@app/api/read_state/ReadStateRepository'; +import {ReadStates} from '@app/api/Tables'; import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; -const ReadStates = defineTable({ - name: 'read_states', - columns: READ_STATE_COLUMNS, - primaryKey: ['user_id', 'channel_id'], -}); const FETCH_READ_STATE = ReadStates.selectCql({ where: [ReadStates.where.eq('user_id'), ReadStates.where.eq('channel_id')], limit: 1, diff --git a/fluxer_api/src/api/read_state/ReadStateRepository.ts b/fluxer_api/src/api/read_state/ReadStateRepository.ts index d24647a3b..908f88497 100644 --- a/fluxer_api/src/api/read_state/ReadStateRepository.ts +++ b/fluxer_api/src/api/read_state/ReadStateRepository.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; -import {channelIdToMessageId} from '@app/api/BrandedTypes'; +import {channelIdToMessageId, createMessageID} from '@app/api/BrandedTypes'; import { BatchBuilder, fetchMany, @@ -9,18 +9,18 @@ import { fetchOne, upsertOne, } from '@app/api/database/CassandraQueryExecution'; -import {defineTable} from '@app/api/database/CassandraTableDsl'; import {Db, type DbOp} from '@app/api/database/CassandraTypes'; import type {ReadStateRow} from '@app/api/database/types/ChannelTypes'; -import {READ_STATE_COLUMNS} from '@app/api/database/types/ChannelTypes'; import {ReadState} from '@app/api/models/ReadState'; -import type {IReadStateRepository, ReadStateUpsert} from '@app/api/read_state/IReadStateRepository'; +import type { + IReadStateRepository, + ReadStateMarker, + ReadStateMentionUpdate, + ReadStateUpsert, +} from '@app/api/read_state/IReadStateRepository'; +import {ReadStates} from '@app/api/Tables'; +import {ReadStateFlags} from '@fluxer/constants/src/ThreadConstants'; -const ReadStates = defineTable({ - name: 'read_states', - columns: READ_STATE_COLUMNS, - primaryKey: ['user_id', 'channel_id'], -}); const FETCH_READ_STATES_CQL = ReadStates.selectCql({ where: ReadStates.where.eq('user_id'), }); @@ -33,6 +33,40 @@ const FETCH_READ_STATES_BY_CHANNEL_IDS_CQL = ReadStates.selectCql({ }); const BULK_READ_STATE_BATCH_QUERY_LIMIT = 50; +function baseReadStateRow(row: ReadStateRow): ReadStateRow { + return { + user_id: row.user_id, + channel_id: row.channel_id, + message_id: row.message_id, + mention_count: row.mention_count, + last_pin_timestamp: row.last_pin_timestamp, + }; +} + +function markerNeeded(marker: ReadStateMarker | null | undefined, row: ReadStateRow | null): marker is ReadStateMarker { + return marker != null && (row?.guild_id == null || row.flags !== marker.flags); +} + +function markerColumns(marker: ReadStateMarker): Pick { + return {flags: marker.flags, guild_id: marker.guildId}; +} + +function markerPatch(marker: ReadStateMarker): Record> { + return {flags: Db.set(marker.flags), guild_id: Db.set(marker.guildId)}; +} + +function markerOf(row: ReadStateRow | null): Pick { + return row?.guild_id != null ? {flags: row.flags ?? null, guild_id: row.guild_id} : {}; +} + +function mentionBaseline(channelId: ChannelID, marker: ReadStateMarker | null | undefined): MessageID { + const baseline = channelIdToMessageId(channelId); + if (marker != null && (marker.flags & ReadStateFlags.IS_THREAD) !== 0) { + return createMessageID(baseline - 1n); + } + return baseline; +} + export class ReadStateRepository implements IReadStateRepository { async listReadStates(userId: UserID): Promise> { const rows = await fetchMany(FETCH_READ_STATES_CQL, {user_id: userId}); @@ -54,8 +88,9 @@ export class ReadStateRepository implements IReadStateRepository { mentionCount = 0, lastPinTimestamp?: Date, manual = false, + marker?: ReadStateMarker | null, ): Promise { - return this.upsertReadStateRow(userId, channelId, messageId, mentionCount, lastPinTimestamp, manual); + return this.upsertReadStateRow(userId, channelId, messageId, mentionCount, lastPinTimestamp, manual, marker); } private async upsertReadStateRow( @@ -65,14 +100,18 @@ export class ReadStateRepository implements IReadStateRepository { mentionCount = 0, lastPinTimestamp?: Date, manual = false, + marker?: ReadStateMarker | null, ): Promise { const currentReadState = await fetchOne(FETCH_READ_STATE_BY_USER_AND_CHANNEL_CQL, { user_id: userId, channel_id: channelId, }); const previous = currentReadState ? new ReadState(currentReadState) : null; + const stamp = markerNeeded(marker, currentReadState); if (!manual && previous?.lastMessageId != null && previous.lastMessageId > messageId) { - return {readState: previous, previous}; + if (!stamp) return {readState: previous, previous}; + await upsertOne(ReadStates.patchByPk({user_id: userId, channel_id: channelId}, markerPatch(marker))); + return {readState: new ReadState({...currentReadState!, ...markerColumns(marker)}), previous}; } const patch: Record> = { message_id: Db.set(messageId), @@ -81,6 +120,7 @@ export class ReadStateRepository implements IReadStateRepository { if (lastPinTimestamp !== undefined) { patch['last_pin_timestamp'] = Db.set(lastPinTimestamp); } + if (stamp) Object.assign(patch, markerPatch(marker)); await upsertOne(ReadStates.patchByPk({user_id: userId, channel_id: channelId}, patch)); const readState = new ReadState({ user_id: userId, @@ -88,6 +128,7 @@ export class ReadStateRepository implements IReadStateRepository { message_id: messageId, mention_count: mentionCount, last_pin_timestamp: lastPinTimestamp ?? currentReadState?.last_pin_timestamp ?? null, + ...(stamp ? markerColumns(marker) : markerOf(currentReadState)), }); return {readState, previous}; } @@ -97,8 +138,9 @@ export class ReadStateRepository implements IReadStateRepository { channelId: ChannelID, messageId: MessageID, incrementBy = 1, + marker?: ReadStateMarker | null, ): Promise { - return this.incrementReadStateMentionsRow(userId, channelId, messageId, incrementBy); + return this.incrementReadStateMentionsRow(userId, channelId, messageId, incrementBy, marker); } private async incrementReadStateMentionsRow( @@ -106,34 +148,35 @@ export class ReadStateRepository implements IReadStateRepository { channelId: ChannelID, messageId: MessageID, incrementBy = 1, + marker?: ReadStateMarker | null, ): Promise { const currentReadState = await fetchOne(FETCH_READ_STATE_BY_USER_AND_CHANNEL_CQL, { user_id: userId, channel_id: channelId, }); if (!currentReadState) { - const baselineMessageId = channelIdToMessageId(channelId); + const baselineMessageId = mentionBaseline(channelId, marker); if (baselineMessageId >= messageId) { return null; } - return (await this.upsertReadStateRow(userId, channelId, baselineMessageId, incrementBy)).readState; + return ( + await this.upsertReadStateRow(userId, channelId, baselineMessageId, incrementBy, undefined, false, marker) + ).readState; } if (currentReadState.message_id != null && currentReadState.message_id >= messageId) { return null; } const newMentionCount = (currentReadState.mention_count || 0) + incrementBy; - const updatedReadState: ReadStateRow = {...currentReadState, mention_count: newMentionCount}; + const updatedReadState: ReadStateRow = { + ...baseReadStateRow(currentReadState), + mention_count: newMentionCount, + ...(markerNeeded(marker, currentReadState) ? markerColumns(marker) : {}), + }; await upsertOne(ReadStates.upsertAll(updatedReadState)); - return new ReadState(updatedReadState); + return new ReadState({...markerOf(currentReadState), ...updatedReadState}); } - async bulkIncrementMentionCounts( - updates: Array<{ - userId: UserID; - channelId: ChannelID; - messageId: MessageID; - }>, - ): Promise< + async bulkIncrementMentionCounts(updates: Array): Promise< Array<{ userId: UserID; channelId: ChannelID; @@ -145,13 +188,7 @@ export class ReadStateRepository implements IReadStateRepository { return this.bulkIncrementMentionCountsRows(updates); } - private async bulkIncrementMentionCountsRows( - updates: Array<{ - userId: UserID; - channelId: ChannelID; - messageId: MessageID; - }>, - ): Promise< + private async bulkIncrementMentionCountsRows(updates: Array): Promise< Array<{ userId: UserID; channelId: ChannelID; @@ -161,11 +198,11 @@ export class ReadStateRepository implements IReadStateRepository { return []; } const existingStates = await Promise.all( - updates.map(({userId, channelId, messageId}) => + updates.map(({userId, channelId, messageId, marker}) => fetchOne(FETCH_READ_STATE_BY_USER_AND_CHANNEL_CQL, { user_id: userId, channel_id: channelId, - }).then((state) => ({userId, channelId, messageId, state})), + }).then((state) => ({userId, channelId, messageId, marker, state})), ), ); const batch = new BatchBuilder(); @@ -173,7 +210,7 @@ export class ReadStateRepository implements IReadStateRepository { userId: UserID; channelId: ChannelID; }> = []; - for (const {userId, channelId, messageId, state} of existingStates) { + for (const {userId, channelId, messageId, marker, state} of existingStates) { if (state) { if (state.message_id != null && state.message_id >= messageId) { continue; @@ -181,12 +218,15 @@ export class ReadStateRepository implements IReadStateRepository { batch.addPrepared( ReadStates.patchByPk( {user_id: userId, channel_id: channelId}, - {mention_count: Db.set((state.mention_count || 0) + 1)}, + { + mention_count: Db.set((state.mention_count || 0) + 1), + ...(markerNeeded(marker, state) ? markerPatch(marker) : {}), + }, ), ); appliedUpdates.push({userId, channelId}); } else { - const baselineMessageId = channelIdToMessageId(channelId); + const baselineMessageId = mentionBaseline(channelId, marker); if (baselineMessageId >= messageId) { continue; } @@ -197,6 +237,7 @@ export class ReadStateRepository implements IReadStateRepository { message_id: baselineMessageId, mention_count: 1, last_pin_timestamp: null, + ...(marker != null ? markerColumns(marker) : {}), }), ); appliedUpdates.push({userId, channelId}); @@ -213,6 +254,7 @@ export class ReadStateRepository implements IReadStateRepository { readStates: Array<{ channelId: ChannelID; messageId: MessageID; + marker?: ReadStateMarker | null; }>, ): Promise> { return this.bulkAckMessageRows(userId, readStates); @@ -223,6 +265,7 @@ export class ReadStateRepository implements IReadStateRepository { readStates: Array<{ channelId: ChannelID; messageId: MessageID; + marker?: ReadStateMarker | null; }>, ): Promise> { const currentRows = await fetchManyInChunks( @@ -234,9 +277,16 @@ export class ReadStateRepository implements IReadStateRepository { const batch = new BatchBuilder(); const results: Array = []; for (const readState of readStates) { - const currentReadState = currentRowsByChannel.get(readState.channelId); + const currentReadState = currentRowsByChannel.get(readState.channelId) ?? null; + const {marker} = readState; + const stamp = markerNeeded(marker, currentReadState); if (currentReadState?.message_id != null && currentReadState.message_id > readState.messageId) { - results.push(new ReadState(currentReadState)); + if (stamp) { + batch.addPrepared( + ReadStates.patchByPk({user_id: userId, channel_id: readState.channelId}, markerPatch(marker)), + ); + } + results.push(new ReadState({...currentReadState, ...(stamp ? markerColumns(marker) : {})})); continue; } batch.addPrepared( @@ -245,6 +295,7 @@ export class ReadStateRepository implements IReadStateRepository { { message_id: Db.set(readState.messageId), mention_count: Db.set(0), + ...(stamp ? markerPatch(marker) : {}), }, ), ); @@ -255,6 +306,7 @@ export class ReadStateRepository implements IReadStateRepository { message_id: readState.messageId, mention_count: 0, last_pin_timestamp: currentReadState?.last_pin_timestamp ?? null, + ...(stamp ? markerColumns(marker) : markerOf(currentReadState)), }), ); } @@ -262,9 +314,17 @@ export class ReadStateRepository implements IReadStateRepository { return results; } - async upsertPinAck(userId: UserID, channelId: ChannelID, lastPinTimestamp: Date): Promise { + async upsertPinAck( + userId: UserID, + channelId: ChannelID, + lastPinTimestamp: Date, + marker?: ReadStateMarker | null, + ): Promise { await upsertOne( - ReadStates.patchByPk({user_id: userId, channel_id: channelId}, {last_pin_timestamp: Db.set(lastPinTimestamp)}), + ReadStates.patchByPk( + {user_id: userId, channel_id: channelId}, + {last_pin_timestamp: Db.set(lastPinTimestamp), ...(marker != null ? markerPatch(marker) : {})}, + ), ); } } diff --git a/fluxer_api/src/api/read_state/ReadStateRequestService.ts b/fluxer_api/src/api/read_state/ReadStateRequestService.ts index 930302606..731c6424f 100644 --- a/fluxer_api/src/api/read_state/ReadStateRequestService.ts +++ b/fluxer_api/src/api/read_state/ReadStateRequestService.ts @@ -13,19 +13,22 @@ import type { interface ReadStateAckBulkParams { userId: UserID; data: ReadStateAckBulkRequest; + capable?: boolean; } interface ReadStateAckParams { userId: UserID; data: ReadStateAckRequest; + capable?: boolean; } export class ReadStateRequestService { constructor(private readStateService: ReadStateService) {} - async bulkAckMessages({userId, data}: ReadStateAckBulkParams): Promise { + async bulkAckMessages({userId, data, capable}: ReadStateAckBulkParams): Promise { await this.readStateService.bulkAckMessages({ userId, + capable, readStates: data.read_states.map((readState) => ({ channelId: createChannelID(readState.channel_id), messageId: createMessageID(readState.message_id), @@ -33,9 +36,10 @@ export class ReadStateRequestService { }); } - async ackReadStates({userId, data}: ReadStateAckParams): Promise { + async ackReadStates({userId, data, capable}: ReadStateAckParams): Promise { const readStates = await this.readStateService.ackReadStates({ userId, + capable, readStates: data.read_states.map((readState) => ({ channelId: createChannelID(readState.channel_id), messageId: createMessageID(readState.message_id), diff --git a/fluxer_api/src/api/read_state/ReadStateResponseMapper.ts b/fluxer_api/src/api/read_state/ReadStateResponseMapper.ts index b2e39e980..d2a68aef5 100644 --- a/fluxer_api/src/api/read_state/ReadStateResponseMapper.ts +++ b/fluxer_api/src/api/read_state/ReadStateResponseMapper.ts @@ -10,5 +10,6 @@ export function mapReadStateResponse(readState: ReadState): ReadStateResponse { last_message_id: readState.lastMessageId?.toString() ?? null, last_pin_timestamp: readState.lastPinTimestamp?.toISOString() ?? null, version: readState.version.toString(), + ...(readState.isMarked ? {flags: readState.flags ?? 0} : {}), }; } diff --git a/fluxer_api/src/api/read_state/ReadStateService.ts b/fluxer_api/src/api/read_state/ReadStateService.ts index a58a32fe4..09cdd25ab 100644 --- a/fluxer_api/src/api/read_state/ReadStateService.ts +++ b/fluxer_api/src/api/read_state/ReadStateService.ts @@ -4,7 +4,18 @@ import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import {Logger} from '@app/api/Logger'; import type {ReadState} from '@app/api/models/ReadState'; -import type {IReadStateRepository} from '@app/api/read_state/IReadStateRepository'; +import type { + IReadStateRepository, + ReadStateMarker, + ReadStateMentionUpdate, +} from '@app/api/read_state/IReadStateRepository'; +import {type ReadStateChannelHint, resolveReadStateMarker} from '@app/api/read_state/ReadStateChannelMeta'; +import {visibleReadStates} from '@app/api/read_state/ReadStateVisibility'; + +function threadScope(readState: ReadState | null | undefined): Record { + if (!readState?.isMarked) return {}; + return {flags: readState.flags ?? 0, __thread_scoped: readState.guildId!.toString()}; +} function hadUnreadThrough(previous: ReadState | null, messageId: MessageID, unreadThrough: MessageID | null): boolean { const previousMessageId = previous?.lastMessageId ?? null; @@ -39,8 +50,16 @@ export class ReadStateService { manual?: boolean; implicit?: {unreadThrough: MessageID | null}; emitGateway?: boolean; + capable?: boolean; + channel?: ReadStateChannelHint | null; }): Promise { const {userId, channelId, messageId, mentionCount, manual, implicit, emitGateway = true} = params; + const marker = await resolveReadStateMarker({ + userId, + channelId, + capable: params.capable === true, + channel: params.channel, + }); const {readState, previous} = await this.repository.upsertReadState( userId, channelId, @@ -48,6 +67,7 @@ export class ReadStateService { mentionCount, undefined, manual ?? false, + marker, ); if (!implicit) { await this.clearPushChannelNotifications({userId, channelId, messageId}); @@ -62,6 +82,7 @@ export class ReadStateService { mentionCount: readState.mentionCount, manual, version: readState.version, + readState, }).catch((error) => { Logger.error( {userId: userId.toString(), channelId: channelId.toString(), error}, @@ -76,6 +97,7 @@ export class ReadStateService { async ackReadStates({ userId, readStates, + capable = false, }: { userId: UserID; readStates: Array<{ @@ -84,6 +106,7 @@ export class ReadStateService { mentionCount?: number; manual?: boolean; }>; + capable?: boolean; }): Promise> { if (readStates.length === 0) { return []; @@ -92,13 +115,15 @@ export class ReadStateService { (readState) => !readState.manual && (readState.mentionCount == null || readState.mentionCount === 0), ); if (canUseBulkAck) { - return await this.bulkAckMessages({ + const acked = await this.bulkAckMessages({ userId, + capable, readStates: readStates.map((readState) => ({ channelId: readState.channelId, messageId: readState.messageId, })), }); + return visibleReadStates(acked, {userId, capable}); } const results: Array = []; for (const readState of readStates) { @@ -109,27 +134,38 @@ export class ReadStateService { messageId: readState.messageId, mentionCount: readState.mentionCount ?? 0, manual: readState.manual, + capable, }), ); } - return results; + return visibleReadStates(results, {userId, capable}); } async bulkAckMessages({ userId, readStates, + capable = false, }: { userId: UserID; readStates: Array<{ channelId: ChannelID; messageId: MessageID; }>; + capable?: boolean; }): Promise> { if (readStates.length === 0) { return []; } try { - const updatedReadStates = await this.repository.bulkAckMessages(userId, readStates); + const markers: Array = capable + ? await Promise.all(readStates.map(({channelId}) => resolveReadStateMarker({userId, channelId, capable}))) + : []; + const updatedReadStates = await this.repository.bulkAckMessages( + userId, + markers.length > 0 + ? readStates.map((readState, index) => ({...readState, marker: markers[index]})) + : readStates, + ); const readStatesByChannel = new Map(updatedReadStates.map((readState) => [readState.channelId, readState])); await Promise.all( readStates.map(({channelId, messageId}) => @@ -140,6 +176,7 @@ export class ReadStateService { messageId: readStatesByChannel.get(channelId)?.lastMessageId ?? messageId, mentionCount: readStatesByChannel.get(channelId)?.mentionCount ?? 0, version: readStatesByChannel.get(channelId)?.version, + readState: readStatesByChannel.get(channelId), }).catch((error) => { Logger.error( {userId: userId.toString(), channelId: channelId.toString(), error}, @@ -170,13 +207,7 @@ export class ReadStateService { await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1); } - async bulkIncrementMentionCounts( - updates: Array<{ - userId: UserID; - channelId: ChannelID; - messageId: MessageID; - }>, - ): Promise { + async bulkIncrementMentionCounts(updates: Array): Promise { if (updates.length === 0) { return; } @@ -188,10 +219,22 @@ export class ReadStateService { } } - async ackPins(params: {userId: UserID; channelId: ChannelID; timestamp: Date}): Promise { + async ackPins(params: { + userId: UserID; + channelId: ChannelID; + timestamp: Date; + capable?: boolean; + channel?: ReadStateChannelHint | null; + }): Promise { const {userId, channelId, timestamp} = params; - await this.repository.upsertPinAck(userId, channelId, timestamp); - await this.dispatchPinsAck({userId, channelId, timestamp}); + const marker = await resolveReadStateMarker({ + userId, + channelId, + capable: params.capable === true, + channel: params.channel, + }); + await this.repository.upsertPinAck(userId, channelId, timestamp, marker); + await this.dispatchPinsAck({userId, channelId, timestamp, marker}); } private async dispatchMessageAck(params: { @@ -201,6 +244,7 @@ export class ReadStateService { mentionCount: number; manual?: boolean; version?: bigint; + readState?: ReadState | null; }): Promise { const {userId, channelId, messageId, mentionCount, manual, version} = params; await this.gatewayService.dispatchPresence({ @@ -212,6 +256,7 @@ export class ReadStateService { mention_count: mentionCount, manual, version: version?.toString(), + ...threadScope(params.readState), }, }); } @@ -231,14 +276,20 @@ export class ReadStateService { }); } - private async dispatchPinsAck(params: {userId: UserID; channelId: ChannelID; timestamp: Date}): Promise { - const {userId, channelId, timestamp} = params; + private async dispatchPinsAck(params: { + userId: UserID; + channelId: ChannelID; + timestamp: Date; + marker: ReadStateMarker | null; + }): Promise { + const {userId, channelId, timestamp, marker} = params; await this.gatewayService.dispatchPresence({ userId, event: 'CHANNEL_PINS_ACK', data: { channel_id: channelId.toString(), timestamp: timestamp.toISOString(), + ...(marker ? {__thread_scoped: marker.guildId.toString()} : {}), }, }); } diff --git a/fluxer_api/src/api/read_state/ReadStateVisibility.ts b/fluxer_api/src/api/read_state/ReadStateVisibility.ts new file mode 100644 index 000000000..73b12f272 --- /dev/null +++ b/fluxer_api/src/api/read_state/ReadStateVisibility.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {UserID} from '@app/api/BrandedTypes'; +import {recipientActive, viewerActive} from '@app/api/experiment/ChannelThreadsGate'; +import type {ReadState} from '@app/api/models/ReadState'; + +export function visibleReadStates( + readStates: Array, + viewer: {userId: UserID; capable: boolean; bot?: boolean}, +): Array { + if (!readStates.some((readState) => readState.isMarked)) return readStates; + const threadViewer = { + kind: 'user', + userId: viewer.userId, + bot: viewer.bot === true, + capable: viewer.capable, + } as const; + return readStates.filter((readState) => readState.guildId === null || viewerActive(threadViewer, readState.guildId)); +} + +export function badgeReadStates(readStates: Array, userId: UserID): Array { + if (!readStates.some((readState) => readState.isMarked)) return readStates; + return readStates.filter( + (readState) => readState.guildId === null || recipientActive(readState.guildId, userId, false), + ); +} diff --git a/fluxer_api/src/api/read_state/ThreadReadStateMarkers.test.ts b/fluxer_api/src/api/read_state/ThreadReadStateMarkers.test.ts new file mode 100644 index 000000000..bd0c0c64a --- /dev/null +++ b/fluxer_api/src/api/read_state/ThreadReadStateMarkers.test.ts @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createChannelID, createGuildID, createMessageID, createUserID} from '@app/api/BrandedTypes'; +import {fetchOne, setCassandraQueryExecutorForTesting, upsertOne} from '@app/api/database/CassandraQueryExecution'; +import type {CassandraParams, PreparedQuery} from '@app/api/database/CassandraTypes'; +import type {ReadStateRow} from '@app/api/database/types/ChannelTypes'; +import { + clearChannelThreadsTaintCacheForTesting, + syncChannelThreadsConfig, +} from '@app/api/experiment/ChannelThreadsGate'; +import {ReadState} from '@app/api/models/ReadState'; +import type {ReadStateMarker} from '@app/api/read_state/IReadStateRepository'; +import { + clearReadStateChannelMetaCacheForTesting, + resolveReadStateMarker, +} from '@app/api/read_state/ReadStateChannelMeta'; +import {ReadStateRepository} from '@app/api/read_state/ReadStateRepository'; +import {mapReadStateResponse} from '@app/api/read_state/ReadStateResponseMapper'; +import {badgeReadStates, visibleReadStates} from '@app/api/read_state/ReadStateVisibility'; +import {ReadStates} from '@app/api/Tables'; +import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ReadStateFlags} from '@fluxer/constants/src/ThreadConstants'; +import { + type ChannelThreadsConfig, + ChannelThreadsConfigSchema, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +const USER = createUserID(1n); +const GUILD = createGuildID(500n); +const THREAD = createChannelID(1000n); +const TEXT = createChannelID(10n); +const THREAD_MARKER: ReadStateMarker = { + flags: ReadStateFlags.IS_GUILD_CHANNEL | ReadStateFlags.IS_THREAD, + guildId: GUILD, +}; +const FETCH_READ_STATE = ReadStates.selectCql({ + where: [ReadStates.where.eq('user_id'), ReadStates.where.eq('channel_id')], + limit: 1, +}); +const LEGACY_UPSERT_CQL = `UPDATE read_states +SET message_id = :message_id, mention_count = :mention_count, last_pin_timestamp = :last_pin_timestamp +WHERE user_id = :user_id AND channel_id = :channel_id; +`; + +class RecordingExecutor extends InMemoryCassandraQueryExecutor { + readonly writes: Array = []; + + override async executeQuery, P extends CassandraParams = CassandraParams>( + query: PreparedQuery

, + ): Promise> { + if (!query.cql.trimStart().toUpperCase().startsWith('SELECT')) this.writes.push(query.cql); + return super.executeQuery(query); + } +} + +class CountingExecutor extends InMemoryCassandraQueryExecutor { + reads = 0; + + override async executeQuery, P extends CassandraParams = CassandraParams>( + query: PreparedQuery

, + ): Promise> { + if (query.cql.trimStart().toUpperCase().startsWith('SELECT')) this.reads++; + return super.executeQuery(query); + } +} + +function load(config: Partial): void { + const raw = JSON.stringify(ChannelThreadsConfigSchema.parse(config)); + syncChannelThreadsConfig(raw, (value) => ChannelThreadsConfigSchema.parse(JSON.parse(value ?? '{}'))); +} + +async function loadRow(channelId = THREAD): Promise { + return fetchOne(FETCH_READ_STATE, {user_id: USER, channel_id: channelId}); +} + +async function seed(channelId: typeof THREAD, messageId: bigint, mentionCount: number): Promise { + await upsertOne( + ReadStates.upsertAll({ + user_id: USER, + channel_id: channelId, + message_id: createMessageID(messageId), + mention_count: mentionCount, + last_pin_timestamp: null, + }), + ); +} + +describe('thread read state markers', () => { + let executor: RecordingExecutor; + const repository = new ReadStateRepository(); + + beforeEach(() => { + executor = new RecordingExecutor(); + setCassandraQueryExecutorForTesting(executor); + clearChannelThreadsTaintCacheForTesting(); + clearReadStateChannelMetaCacheForTesting(); + load({}); + }); + + afterEach(() => { + setCassandraQueryExecutorForTesting(null); + load({}); + }); + + describe('control rows', () => { + it('increments an existing row with the same full-row CQL as before the marker columns existed', async () => { + await seed(TEXT, 100n, 1); + executor.writes.length = 0; + await repository.incrementReadStateMentions(USER, TEXT, createMessageID(101n)); + expect(executor.writes).toEqual([LEGACY_UPSERT_CQL]); + expect(await loadRow(TEXT)).toMatchObject({mention_count: 2, message_id: createMessageID(100n)}); + }); + + it('creates bulk mention rows with the same full-row CQL as before', async () => { + executor.writes.length = 0; + await repository.bulkIncrementMentionCounts([{userId: USER, channelId: TEXT, messageId: createMessageID(20n)}]); + expect(executor.writes).toEqual([LEGACY_UPSERT_CQL]); + }); + + it('never writes marker columns on acks, pins or mention increments', async () => { + await repository.upsertReadState(USER, TEXT, createMessageID(50n), 0); + await repository.bulkAckMessages(USER, [{channelId: TEXT, messageId: createMessageID(60n)}]); + await repository.upsertPinAck(USER, TEXT, new Date('2026-09-01T00:00:00.000Z')); + await repository.incrementReadStateMentions(USER, TEXT, createMessageID(70n)); + expect(executor.writes.some((cql) => cql.includes('flags') || cql.includes('guild_id'))).toBe(false); + const readState = new ReadState((await loadRow(TEXT))!); + expect(readState.isMarked).toBe(false); + expect(mapReadStateResponse(readState)).not.toHaveProperty('flags'); + }); + + it('keeps the channel id as the mention baseline for unmarked rows', async () => { + const readState = await repository.incrementReadStateMentions(USER, THREAD, createMessageID(1001n)); + expect(readState?.lastMessageId).toBe(createMessageID(1000n)); + }); + }); + + describe('marked rows', () => { + it('stamps a new mention row and starts one before the thread id so the starter counts', async () => { + const readState = await repository.incrementReadStateMentions( + USER, + THREAD, + createMessageID(1000n), + 1, + THREAD_MARKER, + ); + expect(readState?.lastMessageId).toBe(createMessageID(999n)); + expect(await loadRow()).toMatchObject({ + flags: THREAD_MARKER.flags, + guild_id: GUILD, + mention_count: 1, + }); + }); + + it('stamps an existing unmarked row on ack and keeps the stamp on later unmarked acks', async () => { + await seed(THREAD, 1005n, 2); + const {readState: acked} = await repository.upsertReadState( + USER, + THREAD, + createMessageID(1010n), + 0, + undefined, + false, + THREAD_MARKER, + ); + expect(acked.isMarked).toBe(true); + const {readState: later} = await repository.upsertReadState(USER, THREAD, createMessageID(1020n), 0); + expect(later.flags).toBe(THREAD_MARKER.flags); + expect(await loadRow()).toMatchObject({guild_id: GUILD, message_id: createMessageID(1020n)}); + }); + + it('stamps rows through bulk ack, bulk mention increments and pin acks', async () => { + const forum = createChannelID(2000n); + const forumMarker: ReadStateMarker = {flags: ReadStateFlags.IS_GUILD_CHANNEL, guildId: GUILD}; + await repository.bulkAckMessages(USER, [ + {channelId: forum, messageId: createMessageID(2001n), marker: forumMarker}, + ]); + await repository.bulkIncrementMentionCounts([ + {userId: USER, channelId: THREAD, messageId: createMessageID(1001n), marker: THREAD_MARKER}, + ]); + const pinned = createChannelID(3000n); + await repository.upsertPinAck(USER, pinned, new Date('2026-09-01T00:00:00.000Z'), THREAD_MARKER); + expect(await loadRow(forum)).toMatchObject({flags: ReadStateFlags.IS_GUILD_CHANNEL, guild_id: GUILD}); + expect(await loadRow()).toMatchObject({flags: THREAD_MARKER.flags, message_id: createMessageID(999n)}); + expect(await loadRow(pinned)).toMatchObject({flags: THREAD_MARKER.flags, guild_id: GUILD}); + }); + + it('carries flags on the wire only for marked rows', async () => { + await repository.incrementReadStateMentions(USER, THREAD, createMessageID(1001n), 1, THREAD_MARKER); + const [readState] = await repository.listReadStates(USER); + expect(mapReadStateResponse(readState!).flags).toBe(THREAD_MARKER.flags); + }); + }); + + describe('visibility', () => { + const control = new ReadState({ + user_id: USER, + channel_id: TEXT, + message_id: createMessageID(5n), + mention_count: 1, + last_pin_timestamp: null, + }); + const marked = new ReadState({ + user_id: USER, + channel_id: THREAD, + message_id: createMessageID(999n), + mention_count: 3, + last_pin_timestamp: null, + flags: THREAD_MARKER.flags, + guild_id: GUILD, + }); + + it('returns the same array when nothing is marked', () => { + const rows = [control]; + expect(visibleReadStates(rows, {userId: USER, capable: false})).toBe(rows); + expect(badgeReadStates(rows, USER)).toBe(rows); + }); + + it('drops marked rows for sessions outside the experiment and keeps them for viewers', () => { + expect(visibleReadStates([control, marked], {userId: USER, capable: true})).toEqual([control]); + load({enabled: true, enabled_guild_ids: [GUILD.toString()], included_user_ids: [USER.toString()]}); + expect(visibleReadStates([control, marked], {userId: USER, capable: false})).toEqual([control]); + expect(visibleReadStates([control, marked], {userId: USER, capable: true})).toEqual([control, marked]); + }); + + it('counts marked rows in the badge for enrolled recipients whatever their client', () => { + expect(badgeReadStates([control, marked], USER)).toEqual([control]); + load({enabled: true, enabled_guild_ids: [GUILD.toString()], included_user_ids: [USER.toString()]}); + expect(badgeReadStates([control, marked], USER)).toEqual([control, marked]); + }); + }); + + describe('marker resolution', () => { + it('does no IO for control users or incapable sessions', async () => { + const counting = new CountingExecutor(); + setCassandraQueryExecutorForTesting(counting); + expect(await resolveReadStateMarker({userId: USER, channelId: THREAD, capable: true})).toBeNull(); + load({enabled: true, enabled_guild_ids: [GUILD.toString()], included_user_ids: [USER.toString()]}); + expect(await resolveReadStateMarker({userId: USER, channelId: THREAD, capable: false})).toBeNull(); + expect(counting.reads).toBe(0); + }); + + it('stamps threads and forums for viewers and nothing else', async () => { + load({enabled: true, enabled_guild_ids: [GUILD.toString()], included_user_ids: [USER.toString()]}); + const resolve = (type: number, guildId: typeof GUILD | null = GUILD) => + resolveReadStateMarker({userId: USER, channelId: THREAD, capable: true, channel: {type, guildId}}); + expect(await resolve(ChannelTypes.PUBLIC_THREAD)).toEqual(THREAD_MARKER); + expect(await resolve(ChannelTypes.PRIVATE_THREAD)).toEqual(THREAD_MARKER); + expect(await resolve(ChannelTypes.GUILD_FORUM)).toEqual({flags: ReadStateFlags.IS_GUILD_CHANNEL, guildId: GUILD}); + expect(await resolve(ChannelTypes.GUILD_TEXT)).toBeNull(); + expect(await resolve(ChannelTypes.PUBLIC_THREAD, createGuildID(9n))).toBeNull(); + }); + }); +}); diff --git a/fluxer_api/src/api/read_state/tests/ThreadReadStates.test.ts b/fluxer_api/src/api/read_state/tests/ThreadReadStates.test.ts new file mode 100644 index 000000000..3a8312a54 --- /dev/null +++ b/fluxer_api/src/api/read_state/tests/ThreadReadStates.test.ts @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {clearReadStateChannelMetaCacheForTesting} from '@app/api/read_state/ReadStateChannelMeta'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ReadStateFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface ReadStateWire { + id: string; + mention_count: number; + last_message_id: string | null; + flags?: number; +} + +interface SessionData { + read_states: Array; + user_guild_settings: Array>; +} + +const THREAD_FLAGS = ReadStateFlags.IS_GUILD_CHANNEL | ReadStateFlags.IS_THREAD; + +describe('thread read states', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + clearReadStateChannelMetaCacheForTesting(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function session(account: TestAccount, capable = false): Promise { + const response = await createBuilder<{data: SessionData}>(harness, '') + .post('/test/rpc-session-init') + .body({ + type: 'session', + token: account.token, + version: 1, + ip: '127.0.0.1', + ...(capable ? {thread_channels_capable: true} : {}), + }) + .expect(200) + .execute(); + return response.data; + } + + async function activeSetup() { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'threads'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, channel.id); + await acceptInvite(harness, member.token, invite.code); + await ensureSessionStarted(harness, owner.token); + await ensureSessionStarted(harness, member.token); + const thread = await threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'topic', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + return {owner, member, guildId: guild.id, channelId: channel.id, thread}; + } + + it('keeps the READY payload of a control user identical once the experiment is live elsewhere', async () => { + const account = await createTestAccount(harness); + await ensureSessionStarted(harness, account.token); + const guild = await createGuild(harness, account.token, 'control'); + const channel = await createChannel(harness, account.token, guild.id, 'general'); + const message = await sendMessage(harness, account.token, channel.id, 'hello'); + await createBuilder(harness, account.token) + .post(`/channels/${channel.id}/messages/${message.id}/ack`) + .body({}) + .expect(204) + .execute(); + await createBuilder(harness, account.token) + .patch(`/users/@me/guilds/${guild.id}/settings`) + .body({ + channel_overrides: { + [channel.id]: {collapsed: false, message_notifications: 1, muted: true, mute_config: null}, + }, + }) + .expect(200) + .execute(); + const baseline = await session(account); + const other = await createTestAccount(harness); + const otherGuild = await createGuild(harness, other.token, 'enrolled'); + await setChannelThreadsConfig({ + enabled: true, + enabled_guild_ids: [otherGuild.id], + included_user_ids: [other.userId], + }); + expect(await session(account)).toEqual(baseline); + expect(await session(account, true)).toEqual(baseline); + }); + + it('stamps the author self-ack and shows thread read states only to capable sessions', async () => { + const s = await activeSetup(); + const sent = await threadsRequest(harness, s.member.token) + .post(`/channels/${s.thread.id}/messages`) + .body({content: 'hello'}) + .execute(); + const capable = await session(s.member, true); + expect(capable.read_states.find((readState) => readState.id === s.thread.id)).toMatchObject({ + last_message_id: sent.id, + flags: THREAD_FLAGS, + }); + const incapable = await session(s.member); + expect(incapable.read_states.some((readState) => readState.id === s.thread.id)).toBe(false); + await setChannelThreadsConfig({enabled: false}); + const killed = await session(s.member, true); + expect(killed.read_states.some((readState) => readState.id === s.thread.id)).toBe(false); + }); + + it('stamps the actor self-ack of thread system messages', async () => { + const s = await activeSetup(); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${s.thread.id}/thread-members/${s.member.userId}`) + .expect(204) + .execute(); + await threadsRequest(harness, s.owner.token) + .patch(`/channels/${s.thread.id}`) + .body({name: 'renamed'}) + .expect(200) + .execute(); + const capable = await session(s.owner, true); + expect(capable.read_states.find((readState) => readState.id === s.thread.id)).toMatchObject({ + flags: THREAD_FLAGS, + }); + const incapable = await session(s.owner); + expect(incapable.read_states.some((readState) => readState.id === s.thread.id)).toBe(false); + }); + + it('returns flags from the bulk ack and scopes MESSAGE_ACK to thread sessions', async () => { + const s = await activeSetup(); + const sent = await threadsRequest(harness, s.member.token) + .post(`/channels/${s.thread.id}/messages`) + .body({content: 'hello'}) + .execute(); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence'); + const acked = await threadsRequest<{read_states: Array}>(harness, s.owner.token) + .post('/read-states/ack') + .body({read_states: [{channel_id: s.thread.id, message_id: sent.id}]}) + .expect(200) + .execute(); + expect(acked.read_states).toEqual([expect.objectContaining({id: s.thread.id, flags: THREAD_FLAGS})]); + const ack = dispatch.mock.calls.find(([params]) => params.event === 'MESSAGE_ACK')?.[0]; + expect(ack?.data).toMatchObject({flags: THREAD_FLAGS, __thread_scoped: s.guildId}); + const incapable = await threadsRequest<{read_states: Array}>(harness, s.owner.token, { + capable: false, + }) + .post('/read-states/ack') + .body({read_states: [{channel_id: s.thread.id, message_id: sent.id}]}) + .expect(200) + .execute(); + expect(incapable.read_states).toEqual([]); + }); + + it('keeps a marked read state when any session calls the deprecated clear endpoint', async () => { + const s = await activeSetup(); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.thread.id}/messages`) + .body({content: 'hello'}) + .execute(); + await threadsRequest(harness, s.member.token, {capable: false}) + .delete(`/channels/${s.thread.id}/messages/ack`) + .expect(204) + .execute(); + expect((await session(s.member, true)).read_states.some((readState) => readState.id === s.thread.id)).toBe(true); + await threadsRequest(harness, s.member.token).delete(`/channels/${s.thread.id}/messages/ack`).expect(204).execute(); + expect((await session(s.member, true)).read_states.some((readState) => readState.id === s.thread.id)).toBe(true); + }); + + it('leaves control acks of ordinary channels unmarked for viewers', async () => { + const s = await activeSetup(); + const message = await sendMessage(harness, s.owner.token, s.channelId, 'plain'); + await threadsRequest(harness, s.member.token) + .post(`/channels/${s.channelId}/messages/${message.id}/ack`) + .body({}) + .expect(204) + .execute(); + const readState = (await session(s.member, true)).read_states.find((entry) => entry.id === s.channelId); + expect(readState).toBeDefined(); + expect(readState).not.toHaveProperty('flags'); + }); +}); diff --git a/fluxer_api/src/api/report/ReportController.ts b/fluxer_api/src/api/report/ReportController.ts index a7cc6ea52..692217808 100644 --- a/fluxer_api/src/api/report/ReportController.ts +++ b/fluxer_api/src/api/report/ReportController.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {RequireEmailAccountIdentity} from '@app/api/middleware/AccountIdentityMiddleware'; import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; @@ -40,6 +41,7 @@ export function ReportController(app: HonoApp) { return ctx.json( await ctx.get('reportRequestService').reportMessage({ user: ctx.get('user'), + viewer: viewerFromCtx(ctx), data: ctx.req.valid('json'), }), ); diff --git a/fluxer_api/src/api/report/ReportRequestService.ts b/fluxer_api/src/api/report/ReportRequestService.ts index 2eb03784b..4540bcf8f 100644 --- a/fluxer_api/src/api/report/ReportRequestService.ts +++ b/fluxer_api/src/api/report/ReportRequestService.ts @@ -2,6 +2,7 @@ import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils'; import {createChannelID, createGuildID, createInviteCode, createMessageID, createUserID} from '@app/api/BrandedTypes'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {User} from '@app/api/models/User'; import {type ReportStatus, reportStatusToString} from '@app/api/report/IReportRepository'; import type {ReportService} from '@app/api/report/ReportService'; @@ -36,10 +37,15 @@ interface ReportRecord { export class ReportRequestService { constructor(private reportService: ReportService) {} - async reportMessage({user, data}: ReportUserRequestContext): Promise { + async reportMessage({ + user, + viewer, + data, + }: ReportUserRequestContext & {viewer: ThreadViewer}): Promise { this.requireVerifiedAccount(user); const report = await this.reportService.reportMessage( this.createReporter(user), + viewer, createChannelID(data.channel_id), createMessageID(data.message_id), data.category, diff --git a/fluxer_api/src/api/report/ReportService.ts b/fluxer_api/src/api/report/ReportService.ts index 929017a68..a97a72f93 100644 --- a/fluxer_api/src/api/report/ReportService.ts +++ b/fluxer_api/src/api/report/ReportService.ts @@ -23,8 +23,10 @@ import { computeEffectiveContentWarning, guildToContentWarningView, } from '@app/api/channel/utils/EffectiveContentWarning'; +import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope'; import type {MessageAttachment} from '@app/api/database/types/MessageTypes'; import type {DSAReportTicketRow} from '@app/api/database/types/ReportTypes'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents'; import type {ReportOutcome, ReportTarget, ResolvedBy} from '@app/api/infrastructure/activity/Contract.generated'; @@ -148,6 +150,7 @@ export class ReportService { async reportMessage( reporter: ReporterMetadata, + viewer: ThreadViewer, channelId: ChannelID, messageId: MessageID, category: string, @@ -157,6 +160,7 @@ export class ReportService { await this.ensureReportRateLimit(this.createReportRateLimitIdentifier(reporterKey), REPORT_RATE_LIMIT_MAX, false); const {authChannel, channel, message} = await this.getReportableMessageForReporter({ reporterId: reporter.id, + viewer, channelId, messageId, }); @@ -628,10 +632,12 @@ export class ReportService { private async getReportableMessageForReporter({ reporterId, + viewer, channelId, messageId, }: { reporterId: UserID | null; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; }): Promise<{ @@ -642,7 +648,11 @@ export class ReportService { if (!reporterId) { throw new UnknownChannelError(); } - const authChannel = await this.messageChannelAuthService.getChannelAuthenticated({userId: reporterId, channelId}); + const authChannel = await this.messageChannelAuthService.getChannelAuthenticated({ + userId: reporterId, + channelId, + viewer, + }); if (!(await this.canAccessMessage(authChannel, messageId))) { throw new UnknownMessageError(); } @@ -720,9 +730,12 @@ export class ReportService { }; } const guildView = guildToContentWarningView(guild); + const scope = channel + ? await resolveNsfwScopeChannel(channel, (channelId) => this.channelRepository.findUnique(channelId)) + : null; let parentCategoryView: ContentWarningChannelLike | null = null; - if (channel?.parentId) { - const parent = await this.channelRepository.findUnique(channel.parentId); + if (scope?.parentId) { + const parent = await this.channelRepository.findUnique(scope.parentId); if (parent) { parentCategoryView = channelToContentWarningView(parent); } @@ -730,8 +743,8 @@ export class ReportService { let effectiveNsfw: boolean | null = null; let effectiveLevel: number | null = null; let effectiveText: string | null = null; - if (channel) { - const channelView = channelToContentWarningView(channel); + if (scope) { + const channelView = channelToContentWarningView(scope); effectiveNsfw = computeEffectiveChannelNsfw(channelView, parentCategoryView, guildView); const effective = computeEffectiveContentWarning(channelView, parentCategoryView, guildView); effectiveLevel = effective.level; diff --git a/fluxer_api/src/api/rpc/RpcService.ts b/fluxer_api/src/api/rpc/RpcService.ts index 129cb21ce..daac24e78 100644 --- a/fluxer_api/src/api/rpc/RpcService.ts +++ b/fluxer_api/src/api/rpc/RpcService.ts @@ -18,6 +18,20 @@ import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import {buildBroadcastMessageData} from '@app/api/channel/services/message/MessageGatewayDispatch'; import {ensurePersonalNotesChannelExists} from '@app/api/channel/services/PersonalNotesChannelRepair'; +import {withThreadParentFieldsMany} from '@app/api/channel/services/thread/ThreadParentSettings'; +import { + listThreadMembersPage, + loadActiveThreadMemberships, + loadForumUnreads, + loadThreadCollectionRpcData, + resolveThreadGateRpcData, +} from '@app/api/channel/services/thread/ThreadRpcData'; +import { + getCompiledChannelThreadsConfig, + guildActive, + isTainted, + type ThreadViewer, +} from '@app/api/experiment/ChannelThreadsGate'; import {mapFavoriteMemeToResponse} from '@app/api/favorite_meme/FavoriteMemeModel'; import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository'; import { @@ -58,6 +72,7 @@ import type {BotAuthService} from '@app/api/oauth/BotAuthService'; import {sendApnsPush} from '@app/api/push/ApnsPushService'; import {mapReadStateResponse} from '@app/api/read_state/ReadStateResponseMapper'; import type {ReadStateService} from '@app/api/read_state/ReadStateService'; +import {badgeReadStates, visibleReadStates} from '@app/api/read_state/ReadStateVisibility'; import {RpcSessionStartService} from '@app/api/rpc/RpcSessionStartService'; import { createRpcTimingNode, @@ -70,6 +85,10 @@ import { import type {IUserRepository} from '@app/api/user/IUserRepository'; import {CustomStatusValidator} from '@app/api/user/services/CustomStatusValidator'; import {getCachedUserPartialResponse} from '@app/api/user/UserCacheHelpers'; +import { + mapUserGuildSettingsForViewer, + SYSTEM_USER_GUILD_SETTINGS_VIEW, +} from '@app/api/user/UserGuildSettingsThreadView'; import {isSignInRefused} from '@app/api/user/UserHelpers'; import { mapRelationshipToResponse, @@ -85,10 +104,12 @@ import {lookupGeoip} from '@app/api/utils/IpUtils'; import type {VoiceAccessContext, VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService'; import type {VoiceService} from '@app/api/voice/VoiceService'; import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository'; +import {ensureGuildThreadPermissionsSeeded} from '@app/api/worker/tasks/SeedThreadPermissions'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; import {AUTOMATIC_VOICE_REGION_ID, ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; import {MAX_PRIVATE_CHANNELS_PER_USER} from '@fluxer/constants/src/LimitConstants'; +import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import { GroupDmAddPermissionFlags, IncomingCallFlags, @@ -129,6 +150,7 @@ interface HandleSessionRequestParams { ip?: string; latitude?: string; longitude?: string; + threadChannelsCapable?: boolean; } interface HandleGuildCollectionRequestParams { @@ -231,6 +253,8 @@ function mapVoiceParticipantSnapshot(result: ListParticipantsResult): { }; } +const THREAD_MEMBERS_RPC_DEFAULT_LIMIT = 1000; + export class RpcService { private readonly customStatusValidator: CustomStatusValidator; private readonly sessionStartService: RpcSessionStartService; @@ -333,6 +357,7 @@ export class RpcService { ip: request.ip, latitude: request.latitude, longitude: request.longitude, + threadChannelsCapable: request.thread_channels_capable === true, }), }; case 'log_guild_crash': { @@ -386,7 +411,7 @@ export class RpcService { type: 'get_user_guild_settings', data: { user_guild_settings: result.user_guild_settings.map((settings) => - settings ? mapUserGuildSettingsToResponse(settings) : null, + settings ? mapUserGuildSettingsToResponse(settings, SYSTEM_USER_GUILD_SETTINGS_VIEW) : null, ), }, }; @@ -653,6 +678,57 @@ export class RpcService { data: {config}, }; } + case 'get_channel_threads_config': { + const config = await this.instanceConfigRepository.getChannelThreadsConfig(); + return { + type: 'get_channel_threads_config', + data: {config}, + }; + } + case 'get_thread_memberships': + return { + type: 'get_thread_memberships', + data: { + members: await loadActiveThreadMemberships( + this.channelRepository, + createGuildID(request.guild_id), + createUserID(request.user_id), + ), + }, + }; + case 'list_thread_members': + return { + type: 'list_thread_members', + data: await listThreadMembersPage(this.channelRepository, { + guildId: createGuildID(request.guild_id), + threadIds: request.thread_ids.map(createChannelID), + limit: request.limit ?? THREAD_MEMBERS_RPC_DEFAULT_LIMIT, + after: request.after_user_id !== undefined ? createUserID(request.after_user_id) : undefined, + }), + }; + case 'forum_unreads': + return { + type: 'forum_unreads', + data: { + threads: await loadForumUnreads(this.channelRepository, { + guildId: createGuildID(request.guild_id), + channelId: createChannelID(request.channel_id), + threads: request.threads.map((thread) => ({ + threadId: createChannelID(thread.thread_id), + ackMessageId: thread.ack_message_id !== undefined ? createMessageID(thread.ack_message_id) : undefined, + })), + }), + }, + }; + case 'guild_thread_flip_data': + return { + type: 'guild_thread_flip_data', + data: await this.handleGuildThreadFlipData({ + guildId: createGuildID(request.guild_id), + pagedMembers: request.paged_members === true, + requestCache, + }), + }; default: { const exhaustiveCheck: never = request; throw new Error( @@ -899,6 +975,7 @@ export class RpcService { ip, latitude, longitude, + threadChannelsCapable = false, }: HandleSessionRequestParams): Promise { const timings = new RpcTimingRecorder(); const normalizedToken = timings.timeSync('normalize_session_token', () => this.normalizeSessionToken(token)); @@ -1171,6 +1248,15 @@ export class RpcService { 'RPC session handling completed', ); }); + const sessionViewer: ThreadViewer = { + kind: 'user', + userId: user.id, + bot: user.isBot, + capable: user.isBot || threadChannelsCapable, + }; + const userGuildSettings = await timings.time('map_user_guild_settings', () => + Promise.all(userData.guildSettings.map((settings) => mapUserGuildSettingsForViewer(settings, sessionViewer))), + ); const responseBuildSteps: RpcTimingSteps = {}; const responseBuildStartedAtNs = startRpcTiming(); const responsePayload = { @@ -1185,14 +1271,12 @@ export class RpcService { }) : null, ), - user_guild_settings: timeRpcStepSync(responseBuildSteps, 'map_user_guild_settings', () => - userData.guildSettings.map(mapUserGuildSettingsToResponse), - ), + user_guild_settings: userGuildSettings, notes: timeRpcStepSync(responseBuildSteps, 'map_notes', () => Object.fromEntries(Array.from(userData.notes.entries()).map(([userId, note]) => [userId.toString(), note])), ), read_states: timeRpcStepSync(responseBuildSteps, 'map_read_states', () => - userData.readStates.map(mapReadStateResponse), + visibleReadStates(userData.readStates, sessionViewer).map(mapReadStateResponse), ), guilds, private_channels: privateChannels, @@ -1287,6 +1371,7 @@ export class RpcService { guildId: GuildID; }): Promise { const guild = await this.getGuildOrThrow(guildId); + await this.ensureThreadPermissionsSeeded(guildId); const memberCount = await this.guildRepository.countMembers(guildId); const repairedMemberCountGuild = await this.updateGuildMemberCount(guild, memberCount); const repairedBannerGuild = await this.repairGuildBannerHeight(repairedMemberCountGuild); @@ -1299,6 +1384,15 @@ export class RpcService { }; } + private async ensureThreadPermissionsSeeded(guildId: GuildID): Promise { + if (!guildActive(guildId)) return; + try { + await ensureGuildThreadPermissionsSeeded(this.channelRepository.threads, guildId); + } catch (error) { + Logger.warn({guildId: guildId.toString(), error}, 'Failed to ensure thread permissions are seeded'); + } + } + private async handleGuildCollectionRolesRequest({ guildId, }: { @@ -1320,9 +1414,23 @@ export class RpcService { requestCache: RequestCache; }): Promise { const guild = await this.getGuildOrThrow(guildId); - const channels = await this.channelRepository.listGuildChannels(guildId); - const repairedGuild = await this.repairDanglingChannelReferences({guild, channels}); - this.repairOrphanedInvitesAndWebhooks({guild: repairedGuild, channels}).catch((error) => { + const channels = await this.channelRepository.listGuildChannels(guildId, 'enrolled'); + const channelIds = new Set(channels.map((channel) => channel.id)); + let maintenanceChannelIds: Promise> | null = null; + const resolveMaintenanceChannelIds = () => { + maintenanceChannelIds ??= this.resolveMaintenanceChannelIds(guildId, channelIds); + return maintenanceChannelIds; + }; + const repairedGuild = await this.repairDanglingChannelReferences({ + guild, + channelIds, + resolveMaintenanceChannelIds, + }); + this.repairOrphanedInvitesAndWebhooks({ + guild: repairedGuild, + channelIds, + resolveMaintenanceChannelIds, + }).catch((error) => { Logger.warn({guildId: guildId.toString(), error}, 'Failed to repair orphaned invites/webhooks'); }); const mappedChannels = await Promise.all( @@ -1335,9 +1443,64 @@ export class RpcService { }), ), ); + const threadGate = await resolveThreadGateRpcData(guildId); + if (!threadGate || !('thread_gate' in threadGate)) { + return { + ...this.createGuildCollectionResponse('channels'), + channels: mappedChannels, + ...threadGate, + }; + } + const channelsWithParentFields = await withThreadParentFieldsMany( + this.channelRepository.threads, + guildId, + channels, + mappedChannels, + ); return { ...this.createGuildCollectionResponse('channels'), + channels: channelsWithParentFields, + ...threadGate, + thread_only_channels: channelsWithParentFields.filter((channel) => THREAD_ONLY_CHANNEL_TYPES.has(channel.type)), + }; + } + + private async handleGuildThreadFlipData({ + guildId, + pagedMembers, + requestCache, + }: { + guildId: GuildID; + pagedMembers: boolean; + requestCache: RequestCache; + }): Promise['data']> { + await this.getGuildOrThrow(guildId); + const active = guildActive(guildId); + const [channels, roles, tainted] = await Promise.all([ + this.channelRepository.listGuildChannels(guildId, 'enrolled'), + this.guildRepository.listRoles(guildId), + isTainted(guildId), + ]); + const configVersion = getCompiledChannelThreadsConfig().config.config_version; + const mappedChannels = await Promise.all( + channels.map((channel) => + mapChannelToResponse({channel, currentUserId: null, userCacheService: this.userCacheService, requestCache}), + ), + ); + return { + config_version: configVersion, + thread_gate: {active, config_version: configVersion}, + thread_tainted: tainted, channels: mappedChannels, + roles: roles.map(mapGuildRoleToResponse), + ...(active + ? await loadThreadCollectionRpcData( + this.channelRepository, + guildId, + channels.filter((channel) => channel.isThreadParent()), + {members: !pagedMembers}, + ) + : {}), }; } @@ -1680,6 +1843,7 @@ export class RpcService { platform: string; app_id: string | null; provider_environment: string | null; + thread_channels?: true; }> > > { @@ -1695,6 +1859,7 @@ export class RpcService { platform: string; app_id: string | null; provider_environment: string | null; + thread_channels?: true; }> > = {}; for (const [userId, subscriptions] of subscriptionsMap.entries()) { @@ -1706,6 +1871,7 @@ export class RpcService { platform: sub.platform, app_id: sub.appId, provider_environment: sub.providerEnvironment, + ...(sub.threadChannels ? {thread_channels: true as const} : {}), })); } return result; @@ -1717,7 +1883,7 @@ export class RpcService { const badgeCounts: Record = {}; await Promise.all( uniqueUserIds.map(async (userId) => { - const readStates = await this.readStateService.getReadStates(userId); + const readStates = badgeReadStates(await this.readStateService.getReadStates(userId), userId); const totalMentions = readStates.reduce((sum, state) => sum + state.mentionCount, 0); badgeCounts[userId.toString()] = totalMentions; }), @@ -1744,9 +1910,26 @@ export class RpcService { return hash.startsWith('a_') ? hash.slice(2) : hash; } - private async repairDanglingChannelReferences(params: {guild: Guild; channels: Array}): Promise { - const {guild, channels} = params; - const channelIds = new Set(channels.map((channel) => channel.id)); + private async resolveMaintenanceChannelIds( + guildId: GuildID, + enrolledChannelIds: ReadonlySet, + ): Promise> { + if (guildActive(guildId) || !(await isTainted(guildId))) return enrolledChannelIds; + const channels = await this.channelRepository.listGuildChannels(guildId, 'maintenance'); + return new Set(channels.map((channel) => channel.id)); + } + + private async repairDanglingChannelReferences(params: { + guild: Guild; + channelIds: ReadonlySet; + resolveMaintenanceChannelIds: () => Promise>; + }): Promise { + const {guild} = params; + const referencedChannelIds = [guild.systemChannelId, guild.rulesChannelId, guild.afkChannelId]; + if (referencedChannelIds.every((channelId) => channelId == null || params.channelIds.has(channelId))) { + return guild; + } + const channelIds = await params.resolveMaintenanceChannelIds(); const danglingSystemChannel = guild.systemChannelId != null && !channelIds.has(guild.systemChannelId); const danglingRulesChannel = guild.rulesChannelId != null && !channelIds.has(guild.rulesChannelId); const danglingAfkChannel = guild.afkChannelId != null && !channelIds.has(guild.afkChannelId); @@ -1848,29 +2031,40 @@ export class RpcService { } } - private async repairOrphanedInvitesAndWebhooks(params: {guild: Guild; channels: Array}): Promise { - const {guild, channels} = params; - const channelIds = new Set(channels.map((channel) => channel.id)); + private async repairOrphanedInvitesAndWebhooks(params: { + guild: Guild; + channelIds: ReadonlySet; + resolveMaintenanceChannelIds: () => Promise>; + }): Promise { + const {guild} = params; const vanityInviteCode = guild.vanityUrlCode ? vanityCodeToInviteCode(guild.vanityUrlCode) : null; const [invites, webhooks] = await Promise.all([ this.inviteRepository.listGuildInvites(guild.id), this.webhookRepository.listByGuild(guild.id), ]); - const orphanedInvites = invites.filter((invite) => { - if (!invite.channelId) { - return false; - } - if (vanityInviteCode && invite.code === vanityInviteCode) { - return false; - } - return !channelIds.has(invite.channelId); - }); - const orphanedWebhooks = webhooks.filter((webhook) => { - if (!webhook.channelId) { - return false; - } - return !channelIds.has(webhook.channelId); + const findOrphans = (channelIds: ReadonlySet) => ({ + invites: invites.filter((invite) => { + if (!invite.channelId) { + return false; + } + if (vanityInviteCode && invite.code === vanityInviteCode) { + return false; + } + return !channelIds.has(invite.channelId); + }), + webhooks: webhooks.filter((webhook) => { + if (!webhook.channelId) { + return false; + } + return !channelIds.has(webhook.channelId); + }), }); + let orphans = findOrphans(params.channelIds); + if (orphans.invites.length > 0 || orphans.webhooks.length > 0) { + orphans = findOrphans(await params.resolveMaintenanceChannelIds()); + } + const orphanedInvites = orphans.invites; + const orphanedWebhooks = orphans.webhooks; if (orphanedInvites.length > 0) { Logger.info( { diff --git a/fluxer_api/src/api/rpc/tests/RpcChannelThreadsConfig.test.ts b/fluxer_api/src/api/rpc/tests/RpcChannelThreadsConfig.test.ts new file mode 100644 index 000000000..9ade603e4 --- /dev/null +++ b/fluxer_api/src/api/rpc/tests/RpcChannelThreadsConfig.test.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import { + applyChannelThreadsConfigUpdate, + type ChannelThreadsConfig, + DEFAULT_CHANNEL_THREADS_CONFIG, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; + +interface ChannelThreadsConfigRpcResponse { + type: 'get_channel_threads_config'; + data: {config: ChannelThreadsConfig}; +} + +describe('RpcService get_channel_threads_config', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + const fetchConfig = () => + createBuilder(harness, '') + .post('/test/rpc-session-init') + .body({type: 'get_channel_threads_config'}) + .expect(HTTP_STATUS.OK) + .execute(); + + test('serves the disabled default before any admin write', async () => { + expect(await fetchConfig()).toEqual({ + type: 'get_channel_threads_config', + data: {config: DEFAULT_CHANNEL_THREADS_CONFIG}, + }); + }); + + test('serves the stored config with its version and sticky ever_enabled', async () => { + const repository = getInstanceConfigRepository(); + await repository.updateChannelThreadsConfig((current) => + applyChannelThreadsConfigUpdate(current, {enabled: true, enabled_guild_ids: ['123']}), + ); + await repository.updateChannelThreadsConfig((current) => + applyChannelThreadsConfigUpdate(current, {enabled: false}), + ); + + const response = await fetchConfig(); + + expect(response.data.config).toMatchObject({ + enabled: false, + ever_enabled: true, + config_version: 2, + enabled_guild_ids: ['123'], + }); + }); +}); diff --git a/fluxer_api/src/api/rpc/tests/RpcGuildThreadSeedTrigger.test.ts b/fluxer_api/src/api/rpc/tests/RpcGuildThreadSeedTrigger.test.ts new file mode 100644 index 000000000..bcd7f7470 --- /dev/null +++ b/fluxer_api/src/api/rpc/tests/RpcGuildThreadSeedTrigger.test.ts @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID} from '@app/api/BrandedTypes'; +import {executeConditional, fetchOne} from '@app/api/database/CassandraQueryExecution'; +import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes'; +import { + clearChannelThreadsTaintCacheForTesting, + insertGuildThreadMarker, + isTainted, + syncChannelThreadsConfig, +} from '@app/api/experiment/ChannelThreadsGate'; +import {GuildRepository} from '@app/api/guild/repositories/GuildRepository'; +import {createGuild} from '@app/api/guild/tests/GuildTestUtils'; +import {getWorkerService} from '@app/api/middleware/ServiceRegistry'; +import {getChannelRepository, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; +import {GuildThreadState} from '@app/api/Tables'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import { + applyChannelThreadsConfigUpdate, + ChannelThreadsConfigSchema, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest'; + +const FETCH_MARKER = GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1}); + +function resetConfig(): void { + syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + clearChannelThreadsTaintCacheForTesting(); +} + +describe('RpcService guild load thread permission seeding', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetConfig(); + }); + + afterAll(async () => { + await harness?.shutdown(); + }); + + const loadCollection = (guildId: string, collection: 'guild' | 'channels') => + createBuilder(harness, '') + .post('/test/rpc-session-init') + .body({type: 'guild_collection', guild_id: guildId, collection}) + .expect(HTTP_STATUS.OK) + .execute(); + const loadGuild = (guildId: string) => loadCollection(guildId, 'guild'); + const setEnabled = (enabled: boolean) => + getInstanceConfigRepository().updateChannelThreadsConfig((current) => + applyChannelThreadsConfigUpdate(current, {enabled, guild_basis_points: 10000}), + ); + + test('marks and seeds a bucketed guild on its first active load, and leaves control guilds alone', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Bucketed'); + const guildId = createGuildID(BigInt(guild.id)); + const addJob = vi.spyOn(getWorkerService(), 'addJob'); + + await loadGuild(guild.id); + expect(await fetchOne(FETCH_MARKER, {guild_id: guildId})).toBeNull(); + expect(addJob).not.toHaveBeenCalled(); + + const landed = await getInstanceConfigRepository().updateChannelThreadsConfig((current) => + applyChannelThreadsConfigUpdate(current, {enabled: true, guild_basis_points: 10000}), + ); + await loadGuild(guild.id); + const marker = await fetchOne(FETCH_MARKER, {guild_id: guildId}); + expect(marker?.first_active_at).toBeInstanceOf(Date); + expect(marker?.perms_seeded_at).toBeNull(); + expect(addJob).toHaveBeenCalledWith( + 'seedThreadPermissions', + {guildId: guild.id, configVersion: landed.config_version}, + {jobKey: `seed-thread-permissions-${guild.id}`}, + ); + + await getChannelRepository().threads.markGuildPermsSeeded(guildId, new Date()); + addJob.mockClear(); + await loadGuild(guild.id); + await loadGuild(guild.id); + expect(addJob).not.toHaveBeenCalled(); + }); + + test('channels loads skip the taint read for control guilds with no dangling references', async () => { + await setEnabled(true); + await setEnabled(false); + clearChannelThreadsTaintCacheForTesting(); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Control'); + const guildId = createGuildID(BigInt(guild.id)); + await loadCollection(guild.id, 'channels'); + await executeConditional( + GuildThreadState.insertIfNotExists({ + guild_id: guildId, + first_active_at: new Date(), + perms_seeded_at: null, + search_backfilled_at: null, + }), + ); + expect(await isTainted(guildId, {fresh: true})).toBe(true); + }); + + test('channels loads keep references to dormant channels in retired guilds and repair real dangling ones', async () => { + await setEnabled(true); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Retired'); + const guildId = createGuildID(BigInt(guild.id)); + await insertGuildThreadMarker(guildId, null); + const channelRepository = getChannelRepository(); + const source = (await channelRepository.findUnique(createChannelID(BigInt(guild.system_channel_id!))))!; + const forumId = createChannelID(BigInt(source.id) + 1n); + await channelRepository.upsert({...source.toRow(), channel_id: forumId, type: ChannelTypes.GUILD_FORUM, name: 'f'}); + const guildRepository = new GuildRepository(); + const stored = (await guildRepository.findUnique(guildId))!; + const missingId = createChannelID(BigInt(source.id) + 2n); + await guildRepository.upsertPartial( + guildId, + {system_channel_id: forumId, rules_channel_id: missingId}, + stored.toRow(), + ); + await setEnabled(false); + await loadCollection(guild.id, 'channels'); + const repaired = (await guildRepository.findUnique(guildId))!; + expect(repaired.systemChannelId).toBe(forumId); + expect(repaired.rulesChannelId).toBeNull(); + }); +}); diff --git a/fluxer_api/src/api/rpc/tests/RpcThreadCollections.test.ts b/fluxer_api/src/api/rpc/tests/RpcThreadCollections.test.ts new file mode 100644 index 000000000..863ef1283 --- /dev/null +++ b/fluxer_api/src/api/rpc/tests/RpcThreadCollections.test.ts @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID} from '@app/api/BrandedTypes'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository'; +import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest'; + +interface RpcEnvelope { + type: string; + data: T; +} + +describe('RpcService thread collections', () => { + let harness: ApiTestHarness; + let owner: TestAccount; + let member: TestAccount; + let guildId: string; + let channelId: string; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function rpc(body: Record): Promise> { + return createBuilder>(harness, '').post('/test/rpc-session-init').body(body).execute(); + } + + async function setup(): Promise { + owner = await createTestAccount(harness); + member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'rpc threads'); + guildId = guild.id; + channelId = (await createChannel(harness, owner.token, guildId, 'general')).id; + const invite = await createChannelInvite(harness, owner.token, channelId); + await acceptInvite(harness, member.token, invite.code); + } + + async function startThread(type: number = ChannelTypes.PUBLIC_THREAD): Promise { + return threadsRequest(harness, owner.token) + .post(`/channels/${channelId}/threads`) + .body({name: 'rpc', type}) + .expect(201) + .execute(); + } + + test('control guild collections carry no thread keys', async () => { + await setup(); + const channels = await rpc>({ + type: 'guild_collection', + guild_id: guildId, + collection: 'channels', + }); + for (const key of ['thread_gate', 'thread_tainted', 'threads', 'thread_members', 'thread_only_channels']) { + expect(channels.data).not.toHaveProperty(key); + } + }); + + test('active guild channel collections carry the gate and forums but load threads separately', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + await setup(); + await startThread(); + const spy = vi.spyOn(ThreadRepository.prototype, 'listActiveThreads'); + try { + const {data} = await rpc<{ + thread_gate: {active: boolean; config_version: number}; + thread_tainted: boolean; + thread_only_channels: Array; + }>({type: 'guild_collection', guild_id: guildId, collection: 'channels'}); + expect(data.thread_gate.active).toBe(true); + expect(data.thread_gate.config_version).toBeGreaterThan(0); + expect(data.thread_tainted).toBe(true); + expect(data.thread_only_channels).toEqual([]); + for (const key of ['threads', 'thread_members', 'thread_parent_settings', 'thread_load_failed']) { + expect(data).not.toHaveProperty(key); + } + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + test('forums without a parent config row carry the default surface fields', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + await setup(); + const forum = await threadsRequest<{id: string}>(harness, owner.token) + .post(`/guilds/${guildId}/channels`) + .body({name: 'forum', type: ChannelTypes.GUILD_FORUM}) + .execute(); + const defaults = { + available_tags: [], + default_tag_setting: 'match_some', + default_forum_layout: 0, + flags: 0, + }; + const collection = await rpc<{thread_only_channels: Array>}>({ + type: 'guild_collection', + guild_id: guildId, + collection: 'channels', + }); + expect(collection.data.thread_only_channels).toEqual([expect.objectContaining({id: forum.id, ...defaults})]); + const flip = await rpc<{thread_parent_settings: Array>}>({ + type: 'guild_thread_flip_data', + guild_id: guildId, + config_version: 0, + paged_members: true, + }); + expect(flip.data.thread_parent_settings).toContainEqual( + expect.objectContaining({channel_id: forum.id, ...defaults}), + ); + expect(flip.data.thread_parent_settings.map((settings) => settings.channel_id)).not.toContain(channelId); + }); + + test('a tainted guild that left the experiment keeps only the taint marker', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + await setup(); + await startThread(); + await setChannelThreadsConfig({enabled: true, user_basis_points: 10000, disabled_guild_ids: [guildId]}); + const {data} = await rpc>({ + type: 'guild_collection', + guild_id: guildId, + collection: 'channels', + }); + expect(data.thread_tainted).toBe(true); + for (const key of ['thread_gate', 'threads', 'thread_members', 'thread_only_channels']) { + expect(data).not.toHaveProperty(key); + } + }); + + test('a tainted guild keeps the taint marker after the kill switch', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + await setup(); + await startThread(); + await setChannelThreadsConfig({enabled: false}); + const {data} = await rpc>({ + type: 'guild_collection', + guild_id: guildId, + collection: 'channels', + }); + expect(data.thread_tainted).toBe(true); + for (const key of ['thread_gate', 'threads', 'thread_members', 'thread_only_channels']) { + expect(data).not.toHaveProperty(key); + } + }); + + test('counts forum post unreads after the acknowledged message', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + await setup(); + await ensureSessionStarted(harness, owner.token); + const forum = await threadsRequest<{id: string}>(harness, owner.token) + .post(`/guilds/${guildId}/channels`) + .body({name: 'forum', type: ChannelTypes.GUILD_FORUM}) + .execute(); + const createPost = () => + threadsRequest<{id: string}>(harness, owner.token) + .post(`/channels/${forum.id}/threads`) + .body({name: 'post', message: {content: 'hello'}}) + .expect(201) + .execute(); + const post = await createPost(); + const quiet = await createPost(); + const replies: Array = []; + for (const content of ['one', 'two', 'three']) { + const message = await threadsRequest<{id: string}>(harness, owner.token) + .post(`/channels/${post.id}/messages`) + .body({content}) + .execute(); + replies.push(message.id); + } + const textThread = await startThread(); + + type Unreads = {threads: Array<{thread_id: string; count?: number; missing?: boolean}>}; + const request = { + type: 'forum_unreads', + guild_id: guildId, + channel_id: forum.id, + user_id: member.userId, + threads: [ + {thread_id: post.id, ack_message_id: post.id}, + {thread_id: quiet.id}, + {thread_id: textThread.id, ack_message_id: textThread.id}, + ], + }; + const unreads = await rpc(request); + expect(unreads.data.threads).toEqual([ + {thread_id: post.id, count: 3}, + {thread_id: quiet.id, missing: true}, + ]); + const caughtUp = await rpc({ + ...request, + threads: [{thread_id: post.id, ack_message_id: replies[1]}], + }); + expect(caughtUp.data.threads).toEqual([{thread_id: post.id, count: 1}]); + + await setChannelThreadsConfig({enabled: false}); + const off = await rpc(request); + expect(off.data.threads).toEqual([]); + }); + + test('serves memberships, member pages and flip data', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + await setup(); + const active = await startThread(); + const privateThread = await startThread(ChannelTypes.PRIVATE_THREAD); + const archived = await startThread(); + await new ChannelRepository().threads.updateState(createChannelID(BigInt(archived.id)), () => ({archived: true})); + await threadsRequest(harness, member.token).put(`/channels/${active.id}/thread-members/@me`).expect(204).execute(); + + const memberships = await rpc<{members: Array<{id: string; user_id: string}>}>({ + type: 'get_thread_memberships', + guild_id: guildId, + user_id: owner.userId, + }); + expect(memberships.data.members.map((entry) => entry.id).sort()).toEqual([active.id, privateThread.id].sort()); + + type MembersPage = { + members: Array<{id: string; user_id: string}>; + has_more: boolean; + next_thread_id: string | null; + next_after_user_id: string | null; + }; + const firstPage = await rpc({ + type: 'list_thread_members', + guild_id: guildId, + thread_ids: [active.id, privateThread.id], + limit: 1, + }); + expect(firstPage.data.members).toHaveLength(1); + expect(firstPage.data).toMatchObject({has_more: true, next_thread_id: active.id}); + const secondPage = await rpc({ + type: 'list_thread_members', + guild_id: guildId, + thread_ids: [active.id, privateThread.id], + limit: 2, + after_user_id: firstPage.data.next_after_user_id, + }); + expect(secondPage.data).toMatchObject({has_more: false, next_thread_id: null}); + expect(secondPage.data.members.map((entry) => entry.id)).toEqual([active.id, privateThread.id]); + expect([firstPage.data.members[0]!.user_id, secondPage.data.members[0]!.user_id].sort()).toEqual( + [owner.userId, member.userId].sort(), + ); + const boundary = await rpc({ + type: 'list_thread_members', + guild_id: guildId, + thread_ids: [active.id, privateThread.id], + limit: 2, + }); + expect(boundary.data).toMatchObject({has_more: true, next_thread_id: privateThread.id, next_after_user_id: null}); + const foreign = await rpc<{members: Array}>({ + type: 'list_thread_members', + guild_id: '1', + thread_ids: [active.id], + }); + expect(foreign.data.members).toEqual([]); + + const flip = await rpc<{ + config_version: number; + thread_gate: {active: boolean}; + thread_tainted: boolean; + channels: Array<{id: string}>; + roles: Array; + threads: Array<{id: string}>; + thread_members: Array<{user_id: string}>; + }>({type: 'guild_thread_flip_data', guild_id: guildId, config_version: 0}); + expect(flip.data.thread_gate.active).toBe(true); + expect(flip.data.thread_tainted).toBe(true); + expect(flip.data.channels.map((channel) => channel.id)).toContain(channelId); + expect(flip.data.roles.length).toBeGreaterThan(0); + expect(flip.data.threads.map((entry) => entry.id).sort()).toEqual([active.id, privateThread.id].sort()); + expect(flip.data.thread_members.map((entry) => entry.user_id).sort()).toEqual( + [owner.userId, owner.userId, member.userId].sort(), + ); + const paged = await rpc & {threads: Array<{id: string}>}>({ + type: 'guild_thread_flip_data', + guild_id: guildId, + config_version: 0, + paged_members: true, + }); + expect(paged.data.threads.map((entry) => entry.id).sort()).toEqual([active.id, privateThread.id].sort()); + expect(paged.data).not.toHaveProperty('thread_members'); + expect(paged.data).toHaveProperty('thread_parent_settings'); + + await setChannelThreadsConfig({enabled: false}); + const off = await rpc>({ + type: 'guild_thread_flip_data', + guild_id: guildId, + config_version: 0, + }); + expect(off.data.thread_gate).toMatchObject({active: false}); + expect(off.data).not.toHaveProperty('threads'); + }); +}); diff --git a/fluxer_api/src/api/search/GlobalSearchService.ts b/fluxer_api/src/api/search/GlobalSearchService.ts index a6d34ea5c..0e93d3265 100644 --- a/fluxer_api/src/api/search/GlobalSearchService.ts +++ b/fluxer_api/src/api/search/GlobalSearchService.ts @@ -8,6 +8,7 @@ import { getDmChannelIdsForScope, isDmScopeChannelForUser, } from '@app/api/channel/services/message/DmScopeUtils'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildService} from '@app/api/guild/services/GuildService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; @@ -58,6 +59,7 @@ export class GlobalSearchService { async searchAcrossDms(params: { userId: UserID; + viewer: ThreadViewer; scope: DmSearchScope; searchParams: MessageSearchRequest; requestCache: RequestCache; @@ -87,11 +89,12 @@ export class GlobalSearchService { if (needsIndexing) { return {indexing: true}; } - return this.runSearch(finalChannelIds, params.userId, params.searchParams, params.requestCache); + return this.runSearch(finalChannelIds, params.userId, params.viewer, params.searchParams, params.requestCache); } async searchAcrossGuildsAndDms(params: { userId: UserID; + viewer: ThreadViewer; dmScope: DmSearchScope; searchParams: MessageSearchRequest; requestCache: RequestCache; @@ -99,15 +102,15 @@ export class GlobalSearchService { requestedChannelIds?: Array; }): Promise { const [guildAccess, includeChannel] = await Promise.all([ - this.guildService.search.collectAccessibleGuildChannels(params.userId), + this.guildService.search.collectAccessibleGuildChannels(params.userId, params.viewer, params.requestedChannelIds), this.findDmScopeContextChannel(params.userId, params.includeChannelId), ]); - const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} = guildAccess; + const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories, threadParents} = guildAccess; if (unindexedChannelIds.size > 0) { await this.queueIndexingChannels(unindexedChannelIds); return {indexing: true}; } - const guildChannelIds = Array.from(accessibleChannels.keys()); + const guildChannelIds = [...accessibleChannels.keys(), ...threadParents.keys()]; const dmChannelIds = await getDmChannelIdsForScope({ scope: params.dmScope, userId: params.userId, @@ -125,7 +128,7 @@ export class GlobalSearchService { canIncludeNsfw = await this.getCanUserAccessNsfw(params.userId); } const finalChannelIds = validatedChannelIds.filter((channelIdStr) => { - const channel = accessibleChannels.get(channelIdStr); + const channel = accessibleChannels.get(channelIdStr) ?? threadParents.get(channelIdStr); if (!channel) { return true; } @@ -150,11 +153,13 @@ export class GlobalSearchService { page, }; } - const needsIndexing = await this.ensureChannelsIndexed(finalChannelIds); + const needsIndexing = await this.ensureChannelsIndexed( + threadParents.size > 0 ? finalChannelIds.filter((id) => !threadParents.has(id)) : finalChannelIds, + ); if (needsIndexing) { return {indexing: true}; } - return this.runSearch(finalChannelIds, params.userId, params.searchParams, params.requestCache); + return this.runSearch(finalChannelIds, params.userId, params.viewer, params.searchParams, params.requestCache); } private filterRequestedChannelIds(available: Array, requested?: Array): Array { @@ -235,6 +240,7 @@ export class GlobalSearchService { private async runSearch( channelIds: Array, userId: UserID, + viewer: ThreadViewer, searchParams: MessageSearchRequest, requestCache: RequestCache, ): Promise { @@ -253,7 +259,12 @@ export class GlobalSearchService { page, cursor, }); - const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache); + const mappedResponses = await this.responseMapper.mapSearchResultToResponses( + result.messages, + userId, + viewer, + requestCache, + ); return { messages: mappedResponses.messages, channels: mappedResponses.channels, @@ -261,6 +272,7 @@ export class GlobalSearchService { hits_per_page: hitsPerPage, page, cursor: result.cursor, + ...(mappedResponses.threads ? {threads: mappedResponses.threads, members: mappedResponses.members} : {}), }; } diff --git a/fluxer_api/src/api/search/ISearchProvider.ts b/fluxer_api/src/api/search/ISearchProvider.ts index 1065104e4..2cfd32aad 100644 --- a/fluxer_api/src/api/search/ISearchProvider.ts +++ b/fluxer_api/src/api/search/ISearchProvider.ts @@ -5,6 +5,7 @@ import type {IGuildMemberSearchService} from '@app/api/search/IGuildMemberSearch import type {IGuildSearchService} from '@app/api/search/IGuildSearchService'; import type {IMessageSearchService} from '@app/api/search/IMessageSearchService'; import type {IReportSearchService} from '@app/api/search/IReportSearchService'; +import type {IThreadSearchService} from '@app/api/search/IThreadSearchService'; import type {IUserSearchService} from '@app/api/search/IUserSearchService'; export interface ISearchProvider { @@ -16,4 +17,5 @@ export interface ISearchProvider { getReportSearchService(): IReportSearchService | null; getAuditLogSearchService(): IAuditLogSearchService | null; getGuildMemberSearchService(): IGuildMemberSearchService | null; + getThreadSearchService(): IThreadSearchService | null; } diff --git a/fluxer_api/src/api/search/IThreadSearchService.ts b/fluxer_api/src/api/search/IThreadSearchService.ts new file mode 100644 index 000000000..7a665931f --- /dev/null +++ b/fluxer_api/src/api/search/IThreadSearchService.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ISearchAdapter} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes'; +import type {SearchableThread, ThreadSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes'; + +export interface IThreadSearchService extends ISearchAdapter { + ready(): Promise; +} diff --git a/fluxer_api/src/api/search/MessageSearchResponseMapper.ts b/fluxer_api/src/api/search/MessageSearchResponseMapper.ts index b543e0b53..ef8dc5533 100644 --- a/fluxer_api/src/api/search/MessageSearchResponseMapper.ts +++ b/fluxer_api/src/api/search/MessageSearchResponseMapper.ts @@ -1,15 +1,28 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {UserID} from '@app/api/BrandedTypes'; +import type {GuildID, UserID} from '@app/api/BrandedTypes'; import {createChannelID} from '@app/api/BrandedTypes'; import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import {createMessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService'; +import {maskThreadArtifactsByChannel} from '@app/api/channel/services/message/ThreadMessageResponses'; +import {mapThreadMemberToResponse, mapThreadToResponse} from '@app/api/channel/services/thread/ThreadMappers'; +import {loadThreadViews} from '@app/api/channel/services/thread/ThreadViews'; +import { + everEnabled, + THREAD_CHANNEL_TYPES, + type ThreadViewer, + viewerActive, +} from '@app/api/experiment/ChannelThreadsGate'; +import {maskChannelResponseThreadBits} from '@app/api/guild/services/ThreadPermissionBits'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {Channel} from '@app/api/models/Channel'; import type {Message} from '@app/api/models/Message'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; import type {MessageSearchResultsResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; const CHANNEL_LOOKUP_CONCURRENCY = 16; @@ -23,10 +36,13 @@ export class MessageSearchResponseMapper { async mapSearchResultToResponses( messages: Array, userId: UserID, + viewer: ThreadViewer, requestCache: RequestCache, ): Promise<{ messages: Array; channels: Array; + threads?: Array; + members?: Array; }> { const orderedChannelIds = Array.from(new Set(messages.map((message) => message.channelId.toString()))); const channels = await mapWithConcurrency(orderedChannelIds, CHANNEL_LOOKUP_CONCURRENCY, (channelId) => @@ -34,7 +50,12 @@ export class MessageSearchResponseMapper { ); const channelById = new Map( channels - .filter((channel): channel is Channel => channel !== null) + .filter( + (channel): channel is Channel => + channel !== null && + (!THREAD_CHANNEL_TYPES.has(channel.type) || + (channel.guildId !== null && viewerActive(viewer, channel.guildId))), + ) .map((channel) => [channel.id.toString(), channel] as const), ); const renderableMessages = messages.filter((message) => channelById.has(message.channelId.toString())); @@ -43,14 +64,16 @@ export class MessageSearchResponseMapper { messages: renderableMessages, channelById, }); - const searchMessages = messageResponses.map( + const searchMessages = maskThreadArtifactsByChannel(viewer, channelById, messageResponses).map( ({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage, ); const respondedChannelIds = new Set(searchMessages.map((message) => message.channel_id)); - const orderedChannels = orderedChannelIds + const respondedChannels = orderedChannelIds .filter((channelId) => respondedChannelIds.has(channelId)) .map((channelId) => channelById.get(channelId)) .filter((channel): channel is Channel => channel !== undefined); + const orderedChannels = respondedChannels.filter((channel) => !THREAD_CHANNEL_TYPES.has(channel.type)); + const threadChannels = respondedChannels.filter((channel) => THREAD_CHANNEL_TYPES.has(channel.type)); const channelResponses = await mapWithConcurrency(orderedChannels, CHANNEL_LOOKUP_CONCURRENCY, (channel) => mapChannelToResponse({ channel, @@ -61,7 +84,48 @@ export class MessageSearchResponseMapper { ); return { messages: searchMessages, - channels: channelResponses, + channels: await this.maskThreadBits(orderedChannels, channelResponses, viewer), + ...(threadChannels.length > 0 ? await this.mapThreads(threadChannels, userId) : {}), }; } + + private async mapThreads( + threadChannels: Array, + userId: UserID, + ): Promise<{threads: Array; members: Array}> { + const threadIds = threadChannels.map((channel) => channel.id); + const states = await this.channelRepository.threads.getStates(threadIds); + const views = await loadThreadViews(this.channelRepository, states); + const members = await mapWithConcurrency(threadIds, CHANNEL_LOOKUP_CONCURRENCY, (threadId) => + this.channelRepository.threads.getMember(threadId, userId), + ); + return { + threads: views.map((view) => mapThreadToResponse(view)), + members: members + .filter((member): member is ThreadMember => member !== null) + .map((member) => mapThreadMemberToResponse(member, {self: true})), + }; + } + + private async maskThreadBits( + channels: Array, + responses: Array, + viewer: ThreadViewer, + ): Promise> { + if (!everEnabled()) return responses; + const guildIds = new Map(); + for (const channel of channels) { + if (channel.guildId) guildIds.set(channel.guildId.toString(), channel.guildId); + } + const maskedById = new Map(); + await Promise.all( + Array.from(guildIds.entries()).map(async ([guildKey, guildId]) => { + const guildResponses = responses.filter((response) => response.guild_id === guildKey); + for (const response of await maskChannelResponseThreadBits(guildId, viewer, guildResponses)) { + maskedById.set(response.id, response); + } + }), + ); + return responses.map((response) => maskedById.get(response.id) ?? response); + } } diff --git a/fluxer_api/src/api/search/SearchNsfwUtils.ts b/fluxer_api/src/api/search/SearchNsfwUtils.ts index 4a8a0d458..36ecb6737 100644 --- a/fluxer_api/src/api/search/SearchNsfwUtils.ts +++ b/fluxer_api/src/api/search/SearchNsfwUtils.ts @@ -1,18 +1,33 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {channelToContentWarningView, computeEffectiveChannelNsfw} from '@app/api/channel/utils/EffectiveContentWarning'; +import { + channelToContentWarningView, + computeEffectiveChannelNsfw, + resolveEffectiveThreadNsfw, +} from '@app/api/channel/utils/EffectiveContentWarning'; import type {Channel} from '@app/api/models/Channel'; import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants'; +import {THREAD_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; export function channelRequiresAgeVerification( channel: Channel, channelsById: ReadonlyMap, guildNsfw: boolean, ): boolean { - const parentCategory = channel.parentId != null ? (channelsById.get(channel.parentId.toString()) ?? null) : null; + const guild = {nsfw: guildNsfw, contentWarningLevel: ContentWarningLevel.INHERIT, contentWarningText: null}; + const parent = channel.parentId != null ? (channelsById.get(channel.parentId.toString()) ?? null) : null; + if (THREAD_CHANNEL_TYPES.has(channel.type)) { + if (!parent) return guildNsfw; + const category = parent.parentId != null ? (channelsById.get(parent.parentId.toString()) ?? null) : null; + return resolveEffectiveThreadNsfw( + channelToContentWarningView(parent), + category ? channelToContentWarningView(category) : null, + guild, + ); + } return computeEffectiveChannelNsfw( channelToContentWarningView(channel), - parentCategory ? channelToContentWarningView(parentCategory) : null, - {nsfw: guildNsfw, contentWarningLevel: ContentWarningLevel.INHERIT, contentWarningText: null}, + parent ? channelToContentWarningView(parent) : null, + guild, ); } diff --git a/fluxer_api/src/api/search/SearchService.ts b/fluxer_api/src/api/search/SearchService.ts index b9f483b54..201f38738 100644 --- a/fluxer_api/src/api/search/SearchService.ts +++ b/fluxer_api/src/api/search/SearchService.ts @@ -3,6 +3,7 @@ import {createChannelID, createGuildID, type UserID} from '@app/api/BrandedTypes'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildService} from '@app/api/guild/services/GuildService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; @@ -55,10 +56,11 @@ export class SearchService { async searchMessages(params: { userId: UserID; + viewer: ThreadViewer; requestCache: RequestCache; data: GlobalSearchMessagesRequest; }): Promise { - const {userId, requestCache, data} = params; + const {userId, viewer, requestCache, data} = params; const {channel_id, channel_ids, context_channel_id, context_guild_id, ...rawSearchParams} = data; const searchParams = normalizeQuotedPhrases(rawSearchParams); const contextChannelId = context_channel_id ? createChannelID(context_channel_id) : null; @@ -70,6 +72,7 @@ export class SearchService { case 'all_guilds': result = await this.guildService.search.searchAllGuilds({ userId, + viewer, channelIds, searchParams, requestCache, @@ -79,6 +82,7 @@ export class SearchService { case 'open_dms': result = await this.globalSearch.searchAcrossDms({ userId, + viewer, scope, searchParams, requestCache, @@ -89,6 +93,7 @@ export class SearchService { case 'all': result = await this.globalSearch.searchAcrossGuildsAndDms({ userId, + viewer, dmScope: 'all_dms', searchParams, requestCache, @@ -99,6 +104,7 @@ export class SearchService { case 'open_dms_and_all_guilds': result = await this.globalSearch.searchAcrossGuildsAndDms({ userId, + viewer, dmScope: 'open_dms', searchParams, requestCache, @@ -110,6 +116,7 @@ export class SearchService { if (contextGuildId) { result = await this.guildService.search.searchMessages({ userId, + viewer, guildId: contextGuildId, channelIds, searchParams, @@ -118,6 +125,7 @@ export class SearchService { } else if (contextChannelId) { result = await this.channelService.messages.retrieval.searchMessages({ userId, + viewer, channelId: contextChannelId, searchParams, requestCache, diff --git a/fluxer_api/src/api/search/ThreadSearchScope.test.ts b/fluxer_api/src/api/search/ThreadSearchScope.test.ts new file mode 100644 index 000000000..ed17f8442 --- /dev/null +++ b/fluxer_api/src/api/search/ThreadSearchScope.test.ts @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type ChannelID, createChannelID, createGuildID, createUserID, type GuildID} from '@app/api/BrandedTypes'; +import { + type CassandraQueryExecutorForTesting, + setCassandraQueryExecutorForTesting, + upsertOne, +} from '@app/api/database/CassandraQueryExecution'; +import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import {accessibleThreadIds} from '@app/api/search/ThreadSearchScope'; +import {ThreadsByParent} from '@app/api/Tables'; +import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +vi.mock('@fluxer/constants/src/ThreadConstants', async (importOriginal) => ({ + ...(await importOriginal()), + THREAD_SCOPE_MAX: 300, +})); + +const USER_ID = createUserID(1n); +const GUILD_A = createGuildID(10n); +const GUILD_B = createGuildID(20n); +const PARENT_A = createChannelID(100n); +const PARENT_B = createChannelID(200n); + +class CountingExecutor implements CassandraQueryExecutorForTesting { + readonly inner = new InMemoryCassandraQueryExecutor(); + parentReads = 0; + + async executeQuery, P extends CassandraParams = CassandraParams>( + query: PreparedQuery

, + ): Promise> { + if (query.kvMeta?.action === 'select' && query.kvMeta.table.name === ThreadsByParent.name) this.parentReads++; + return this.inner.executeQuery(query); + } + + async executeBatch( + queries: Array<{query: string; params: object; meta?: KvQueryMeta}>, + atomic?: boolean, + ): Promise { + await this.inner.executeBatch(queries, atomic); + } +} + +async function seedThreads(guildId: GuildID, parentId: ChannelID, from: bigint, count: number, type: number) { + for (let offset = 0n; offset < BigInt(count); offset++) { + await upsertOne( + ThreadsByParent.upsertAll({ + parent_id: parentId, + thread_id: createChannelID(from + offset), + guild_id: guildId, + type, + }), + ); + } +} + +describe('accessibleThreadIds', () => { + let executor: CountingExecutor; + + beforeEach(() => { + executor = new CountingExecutor(); + setCassandraQueryExecutorForTesting(executor); + }); + + afterEach(() => { + executor.inner.reset(); + setCassandraQueryExecutorForTesting(null); + }); + + it('merges guilds newest first and stops enumerating at the shared cap', async () => { + await seedThreads(GUILD_A, PARENT_A, 1_000n, 600, ChannelTypes.PUBLIC_THREAD); + await seedThreads(GUILD_B, PARENT_B, 2_000n, 600, ChannelTypes.PUBLIC_THREAD); + await seedThreads(GUILD_B, PARENT_B, 3_000n, 1, ChannelTypes.PRIVATE_THREAD); + const gatewayService = {getUserPermissions: vi.fn(async () => 0n)} as unknown as IGatewayService; + executor.parentReads = 0; + + const scope = await accessibleThreadIds({ + gatewayService, + userId: USER_ID, + groups: [ + {guildId: GUILD_A, parentIds: [PARENT_A]}, + {guildId: GUILD_B, parentIds: [PARENT_B]}, + ], + }); + + expect(scope.size).toBe(300); + expect([...scope.values()].every((parentId) => parentId === PARENT_B)).toBe(true); + expect(scope.has('2599')).toBe(true); + expect(scope.has('2299')).toBe(false); + expect(scope.has('3000')).toBe(false); + expect(executor.parentReads).toBe(3); + }); + + it('keeps the newest threads across many interleaved parents', async () => { + const parents = Array.from({length: 40}, (_, index) => createChannelID(500n + BigInt(index))); + for (const [index, parentId] of parents.entries()) { + for (let step = 0n; step < 10n; step++) { + await upsertOne( + ThreadsByParent.upsertAll({ + parent_id: parentId, + thread_id: createChannelID(10_000n + step * 40n + BigInt(index)), + guild_id: GUILD_A, + type: ChannelTypes.PUBLIC_THREAD, + }), + ); + } + } + const gatewayService = {getUserPermissions: vi.fn(async () => 0n)} as unknown as IGatewayService; + + const scope = await accessibleThreadIds({ + gatewayService, + userId: USER_ID, + groups: [{guildId: GUILD_A, parentIds: parents}], + }); + + expect([...scope.keys()]).toEqual(Array.from({length: 300}, (_, index) => String(10_399 - index))); + expect(scope.get('10399')).toBe(parents[39]); + }); +}); diff --git a/fluxer_api/src/api/search/ThreadSearchScope.ts b/fluxer_api/src/api/search/ThreadSearchScope.ts new file mode 100644 index 000000000..889615cb9 --- /dev/null +++ b/fluxer_api/src/api/search/ThreadSearchScope.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; +import {fetchMany} from '@app/api/database/CassandraQueryExecution'; +import type {ThreadMembersByUserRow, ThreadsByParentRow} from '@app/api/database/types/ThreadTypes'; +import {isGuildMemberTimedOut} from '@app/api/guild/GuildModel'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import {ThreadMembersByUser, ThreadsByParent} from '@app/api/Tables'; +import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils'; +import {PUBLIC_THREAD_CHANNEL_TYPES, THREAD_SCOPE_MAX} from '@fluxer/constants/src/ThreadConstants'; +import {isThreadModerator, withImplicitThreadBits} from '@fluxer/constants/src/ThreadPermissionUtils'; + +const SCOPE_PAGE_SIZE = 250; +const SCOPE_FETCH_CONCURRENCY = 16; + +const FETCH_THREADS_FIRST = ThreadsByParent.select({ + where: ThreadsByParent.where.eq('parent_id'), + orderBy: {col: 'thread_id', direction: 'DESC'}, + limit: SCOPE_PAGE_SIZE, +}); +const FETCH_THREADS_BEFORE = ThreadsByParent.select({ + where: [ThreadsByParent.where.eq('parent_id'), ThreadsByParent.where.lt('thread_id', 'before')], + orderBy: {col: 'thread_id', direction: 'DESC'}, + limit: SCOPE_PAGE_SIZE, +}); +const FETCH_JOINED_THREADS = ThreadMembersByUser.select({ + columns: ['thread_id'], + where: [ThreadMembersByUser.where.eq('user_id'), ThreadMembersByUser.where.eq('guild_id')], +}); + +async function joinedThreadIds(userId: UserID, guildId: GuildID): Promise> { + return new Set( + ( + await fetchMany>( + FETCH_JOINED_THREADS.bind({user_id: userId, guild_id: guildId}), + ) + ).map((row) => row.thread_id.toString()), + ); +} + +function threadModeratorCheck( + gatewayService: IGatewayService, + userId: UserID, + guildId: GuildID, +): (parentId: ChannelID) => Promise { + const byParent = new Map>(); + let timedOut: Promise | undefined; + const check = async (parentId: ChannelID): Promise => { + const permissions = withImplicitThreadBits( + await gatewayService.getUserPermissions({guildId, userId, channelId: parentId}), + ); + if (isThreadModerator(permissions, {isOwner: false, timedOut: true})) return true; + if (!isThreadModerator(permissions, {isOwner: false, timedOut: false})) return false; + timedOut ??= gatewayService + .getGuildMember({guildId, userId}) + .then((result) => isGuildMemberTimedOut(result.memberData)); + return !(await timedOut); + }; + return (parentId) => { + const key = parentId.toString(); + let result = byParent.get(key); + if (result === undefined) { + result = check(parentId); + byParent.set(key, result); + } + return result; + }; +} + +export async function accessibleRequestedThreadIds(params: { + gatewayService: IGatewayService; + userId: UserID; + guildId: GuildID; + threads: ReadonlyArray<{id: ChannelID; parentId: ChannelID | null; type: number}>; +}): Promise> { + const {gatewayService, userId, guildId} = params; + const accessible = new Set(); + const pending = params.threads.filter((thread) => { + if (thread.parentId === null) return false; + if (!PUBLIC_THREAD_CHANNEL_TYPES.has(thread.type)) return true; + accessible.add(thread.id.toString()); + return false; + }); + if (pending.length === 0) return accessible; + const joined = await joinedThreadIds(userId, guildId); + const moderates = threadModeratorCheck(gatewayService, userId, guildId); + for (const thread of pending) { + const threadId = thread.id.toString(); + if (!joined.has(threadId) && !(await moderates(thread.parentId!))) continue; + accessible.add(threadId); + } + return accessible; +} + +function fetchParentPage(parentId: ChannelID, before: ChannelID | null): Promise> { + return fetchMany( + before === null + ? FETCH_THREADS_FIRST.bind({parent_id: parentId}) + : FETCH_THREADS_BEFORE.bind({parent_id: parentId, before}), + ); +} + +export async function accessibleThreadIds(params: { + gatewayService: IGatewayService; + userId: UserID; + groups: ReadonlyArray<{guildId: GuildID; parentIds: ReadonlyArray}>; +}): Promise> { + const {gatewayService, userId} = params; + const scope = new Map(); + const pending = params.groups.flatMap(({guildId, parentIds}) => { + if (parentIds.length === 0) return []; + let joinedIds: Promise> | undefined; + const joined = () => (joinedIds ??= joinedThreadIds(userId, guildId)); + const moderates = threadModeratorCheck(gatewayService, userId, guildId); + return parentIds.map((parentId) => ({parentId, joined, moderates})); + }); + if (pending.length === 0) return scope; + const cursors = await mapWithConcurrency(pending, SCOPE_FETCH_CONCURRENCY, async (entry) => ({ + ...entry, + rows: await fetchParentPage(entry.parentId, null), + index: 0, + })); + const heap = cursors.filter((cursor) => cursor.rows.length > 0); + const headId = (at: number) => heap[at]!.rows[heap[at]!.index]!.thread_id; + const siftDown = (from: number) => { + let at = from; + for (;;) { + let largest = at; + for (const child of [2 * at + 1, 2 * at + 2]) { + if (child < heap.length && headId(child) > headId(largest)) largest = child; + } + if (largest === at) return; + [heap[at], heap[largest]] = [heap[largest]!, heap[at]!]; + at = largest; + } + }; + for (let at = Math.floor(heap.length / 2) - 1; at >= 0; at--) siftDown(at); + while (scope.size < THREAD_SCOPE_MAX && heap.length > 0) { + const head = heap[0]!; + const row = head.rows[head.index++]!; + if (head.index === head.rows.length && head.rows.length === SCOPE_PAGE_SIZE) { + head.rows = await fetchParentPage(head.parentId, row.thread_id); + head.index = 0; + } + if (head.index >= head.rows.length) { + heap[0] = heap[heap.length - 1]!; + heap.pop(); + } + siftDown(0); + const threadId = row.thread_id.toString(); + if ( + !PUBLIC_THREAD_CHANNEL_TYPES.has(row.type) && + !(await head.joined()).has(threadId) && + !(await head.moderates(head.parentId)) + ) { + continue; + } + scope.set(threadId, head.parentId); + } + return scope; +} diff --git a/fluxer_api/src/api/search/controllers/SearchController.ts b/fluxer_api/src/api/search/controllers/SearchController.ts index f423fc23d..3ad52b2d6 100644 --- a/fluxer_api/src/api/search/controllers/SearchController.ts +++ b/fluxer_api/src/api/search/controllers/SearchController.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; @@ -35,7 +36,9 @@ export function SearchController(app: HonoApp) { } const userId = user.id; const requestCache = ctx.get('requestCache'); - const result = await ctx.get('searchService').searchMessages({userId, requestCache, data: params}); + const result = await ctx + .get('searchService') + .searchMessages({viewer: viewerFromCtx(ctx), userId, requestCache, data: params}); return ctx.json(result); }, ); diff --git a/fluxer_api/src/api/search/meilisearch/MeilisearchDomainAdapters.ts b/fluxer_api/src/api/search/meilisearch/MeilisearchDomainAdapters.ts index 3e1f010ec..6cfe06e99 100644 --- a/fluxer_api/src/api/search/meilisearch/MeilisearchDomainAdapters.ts +++ b/fluxer_api/src/api/search/meilisearch/MeilisearchDomainAdapters.ts @@ -25,7 +25,10 @@ import type { SearchableGuildMember, SearchableMessage, SearchableReport, + SearchableThread, SearchableUser, + ThreadSearchCursor, + ThreadSearchFilters, UserSearchFilters, } from '@fluxer/schema/src/contracts/search/SearchDocumentTypes'; import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; @@ -298,3 +301,51 @@ export class MeilisearchGuildMemberAdapter extends MeilisearchIndexAdapter< }); } } + +const THREAD_SORT_FIELDS = { + last_message_time: 'lastMessageAt', + archive_time: 'archivedAt', + creation_time: 'createdAt', +} as const; + +function threadCursorFilter(cursor: ThreadSearchCursor, op: '>' | '<'): MeilisearchFilter { + return `(createdAt ${op} ${cursor.createdAt} OR (createdAt = ${cursor.createdAt} AND idSequence ${op} ${cursor.idSequence}))`; +} + +function buildThreadFilters(filters: ThreadSearchFilters): Array { + const clauses: Array = [ + meiliTermFilter('guildId', filters.guildId), + meiliTermFilter('parentId', filters.parentId), + ]; + if (filters.publicOnly) { + const privateIds = meiliTermsFilter('id', filters.privateThreadIds ?? []); + const publicTypes = meiliTermsFilter('type', [10, 11]); + clauses.push(privateIds ? `(${publicTypes} OR ${privateIds})` : publicTypes); + } + if (filters.archived !== undefined) clauses.push(meiliTermFilter('archived', filters.archived)); + if (filters.tagIds && filters.tagIds.length > 0) { + if (filters.tagSetting === 'match_all') clauses.push(...meiliAndTerms('appliedTagIds', filters.tagIds)); + else clauses.push(meiliTermsFilter('appliedTagIds', filters.tagIds)); + } + if (filters.after) clauses.push(threadCursorFilter(filters.after, '>')); + if (filters.before) clauses.push(threadCursorFilter(filters.before, '<')); + return compactMeiliFilters(clauses); +} + +export class MeilisearchThreadAdapter extends MeilisearchIndexAdapter { + constructor(options: MeilisearchAdapterOptions) { + super({ + client: options.client, + index: MEILISEARCH_INDEX_DEFINITIONS.threads, + buildFilters: buildThreadFilters, + buildSort: (filters) => { + const sortBy = filters.sortBy ?? 'last_message_time'; + if (sortBy === 'relevance') return undefined; + const direction = filters.sortOrder ?? 'desc'; + return [...new Set([THREAD_SORT_FIELDS[sortBy], 'createdAt', 'idSequence'])].map( + (field) => `${field}:${direction}`, + ); + }, + }); + } +} diff --git a/fluxer_api/src/api/search/meilisearch/MeilisearchIndexDefinitions.ts b/fluxer_api/src/api/search/meilisearch/MeilisearchIndexDefinitions.ts index 8e5629833..4c3a4956d 100644 --- a/fluxer_api/src/api/search/meilisearch/MeilisearchIndexDefinitions.ts +++ b/fluxer_api/src/api/search/meilisearch/MeilisearchIndexDefinitions.ts @@ -94,6 +94,13 @@ export const MEILISEARCH_INDEX_DEFINITIONS: Record ({ + ...(await importOriginal()), + SEARCH_INDEX_ENQUEUE_MAX: 1, +})); + +describe('guild search thread scope', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness({search: 'enabled'}); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup() { + const owner = await createTestAccount(harness); + await ensureSessionStarted(harness, owner.token); + const guild = await createGuild(harness, owner.token, 'search'); + const first = await createChannel(harness, owner.token, guild.id, 'first'); + await createChannel(harness, owner.token, guild.id, 'second'); + return {owner, guildId: guild.id, channelId: first.id}; + } + + function search(account: TestAccount, body: Record) { + return threadsRequest<{indexing?: boolean}>(harness, account.token) + .post('/search/messages') + .body({content: 'needle', ...body}) + .expect(200) + .execute(); + } + + function spyIndexJobs() { + const addJob = vi.spyOn(getWorkerService(), 'addJob'); + return () => addJob.mock.calls.filter(([task]) => task === 'indexChannelMessages'); + } + + it('caps index enqueues per search in an active guild', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const s = await setup(); + const indexJobs = spyIndexJobs(); + expect(await search(s.owner, {context_guild_id: s.guildId})).toEqual({indexing: true}); + expect(indexJobs()).toHaveLength(1); + }); + + it('keeps enqueueing every unindexed channel in a control guild', async () => { + const s = await setup(); + const indexJobs = spyIndexJobs(); + expect(await search(s.owner, {context_guild_id: s.guildId})).toEqual({indexing: true}); + expect(indexJobs().length).toBeGreaterThan(1); + }); + + it('never reports indexing for threads', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const s = await setup(); + await markGuildChannelsAsIndexed(harness, s.owner.token, s.guildId); + const thread = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.channelId}/threads`) + .body({name: 'topic', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + const indexJobs = spyIndexJobs(); + expect(await search(s.owner, {context_guild_id: s.guildId})).not.toHaveProperty('indexing'); + expect(await search(s.owner, {context_guild_id: s.guildId, channel_ids: [thread.id]})).not.toHaveProperty( + 'indexing', + ); + expect(indexJobs()).toHaveLength(0); + }); +}); diff --git a/fluxer_api/src/api/search/tests/MessageSearchThreadPermissionBits.test.ts b/fluxer_api/src/api/search/tests/MessageSearchThreadPermissionBits.test.ts new file mode 100644 index 000000000..1fdcace9a --- /dev/null +++ b/fluxer_api/src/api/search/tests/MessageSearchThreadPermissionBits.test.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import { + clearChannelThreadsTaintCacheForTesting, + syncChannelThreadsConfig, +} from '@app/api/experiment/ChannelThreadsGate'; +import {createGuild} from '@app/api/guild/tests/GuildTestUtils'; +import {markGuildChannelsAsIndexed, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; +import { + type ChannelThreadsConfig, + ChannelThreadsConfigSchema, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type {MessageSearchResultsResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; + +const FEATURES = 'X-Fluxer-Features'; +const CAPABLE = 'channel_threads'; + +async function setConfig(patch: Partial): Promise { + await getInstanceConfigRepository().updateChannelThreadsConfig((current) => + ChannelThreadsConfigSchema.parse({ + ...patch, + ever_enabled: current.ever_enabled || patch.enabled === true, + config_version: current.config_version + 1, + }), + ); + clearChannelThreadsTaintCacheForTesting(); +} + +function resetConfig(): void { + syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + clearChannelThreadsTaintCacheForTesting(); +} + +describe('message search thread permission bits', () => { + let harness: ApiTestHarness; + + beforeEach(async () => { + harness = await createApiTestHarness({search: 'enabled'}); + resetConfig(); + }); + + afterEach(async () => { + resetConfig(); + await harness.shutdown(); + }); + + test('search channel overwrites are masked for non-viewers in tainted guilds', async () => { + await setConfig({enabled: true, guild_basis_points: 10000, user_basis_points: 10000}); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Search masking'); + const allow = Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS; + const channel = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .header(FEATURES, CAPABLE) + .body({ + name: 'search-overwrites', + type: ChannelTypes.GUILD_TEXT, + permission_overwrites: [{id: guild.id, type: 0, allow: allow.toString(), deny: '0'}], + }) + .execute(); + const marker = `thread-bits-${Date.now()}`; + await sendMessage(harness, owner.token, channel.id, marker); + await markGuildChannelsAsIndexed(harness, owner.token, guild.id); + + const searchAllow = async (body: Record, capable: boolean) => { + const builder = createBuilder(harness, owner.token) + .post('/search/messages') + .body({content: marker, ...body}); + const result = await (capable ? builder.header(FEATURES, CAPABLE) : builder).execute(); + const entry = result.channels.find((candidate) => candidate.id === channel.id); + expect(entry).toBeDefined(); + return BigInt(entry?.permission_overwrites?.[0]?.allow ?? '0'); + }; + const scopes: Array> = [ + {context_channel_id: channel.id}, + {context_guild_id: guild.id}, + {scope: 'all_guilds'}, + ]; + for (const scope of scopes) { + expect(await searchAllow(scope, true)).toBe(allow); + expect(await searchAllow(scope, false)).toBe(Permissions.SEND_MESSAGES); + } + + await setConfig({enabled: false}); + for (const scope of scopes) { + expect(await searchAllow(scope, true)).toBe(Permissions.SEND_MESSAGES); + } + }); +}); diff --git a/fluxer_api/src/api/search/tests/ThreadMessageSearch.test.ts b/fluxer_api/src/api/search/tests/ThreadMessageSearch.test.ts new file mode 100644 index 000000000..c19899241 --- /dev/null +++ b/fluxer_api/src/api/search/tests/ThreadMessageSearch.test.ts @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID} from '@app/api/BrandedTypes'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import { + acceptInvite, + addMemberRole, + createChannel, + createChannelInvite, + createGuild, +} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted, markGuildChannelsAsIndexed, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {getMessageSearchService} from '@app/api/SearchFactory'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {TEST_TIMEOUTS, wait} from '@app/api/test/TestConstants'; +import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants'; +import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +interface SearchResult { + messages: Array<{id: string; channel_id: string; flags: number}>; + channels: Array<{id: string}>; + threads?: Array<{id: string}>; + members?: Array<{id?: string; user_id?: string}>; +} + +describe('thread message search', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness({search: 'enabled'}); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup() { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'search'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, channel.id); + await acceptInvite(harness, member.token, invite.code); + await ensureSessionStarted(harness, owner.token); + await ensureSessionStarted(harness, member.token); + await markGuildChannelsAsIndexed(harness, owner.token, guild.id); + const startThread = (type: number) => + threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'topic', type}) + .expect(201) + .execute(); + const publicThread = await startThread(ChannelTypes.PUBLIC_THREAD); + const privateThread = await startThread(ChannelTypes.PRIVATE_THREAD); + const post = (token: string, channelId: string, content: string) => + threadsRequest(harness, token).post(`/channels/${channelId}/messages`).body({content}).execute(); + const source = await sendMessage(harness, owner.token, channel.id, 'needle source'); + await threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/messages/${source.id}/threads`) + .body({name: 'from source'}) + .expect(201) + .execute(); + await post(member.token, publicThread.id, 'needle public'); + await post(owner.token, privateThread.id, 'needle private'); + return {owner, member, guildId: guild.id, channelId: channel.id, publicThread, privateThread, source}; + } + + async function search( + account: TestAccount, + body: Record, + options: {capable?: boolean; status?: number; code?: string; expected?: number} = {}, + ): Promise { + for (let attempt = 0; ; attempt++) { + const result = await threadsRequest(harness, account.token, {capable: options.capable ?? true}) + .post('/search/messages') + .body({content: 'needle', ...body}) + .expect(options.status ?? 200, options.code) + .execute(); + if (options.status !== undefined || attempt >= 20) return result; + if ('messages' in result && result.messages.length >= (options.expected ?? 1)) return result; + await wait(TEST_TIMEOUTS.QUICK); + } + } + + it('searches public threads for viewers and returns threads and members', async () => { + const s = await setup(); + const result = await search(s.member, {context_guild_id: s.guildId}, {expected: 2}); + const channelIds = new Set(result.messages.map((message) => message.channel_id)); + expect(channelIds).toEqual(new Set([s.channelId, s.publicThread.id])); + expect(result.channels.map((channel) => channel.id)).toEqual([s.channelId]); + expect(result.threads?.map((thread) => thread.id)).toEqual([s.publicThread.id]); + expect(result.members).toEqual([expect.objectContaining({id: s.publicThread.id, user_id: s.member.userId})]); + const source = result.messages.find((message) => message.id === s.source.id); + expect((source?.flags ?? 0) & ServerMessageFlags.HAS_THREAD).toBe(ServerMessageFlags.HAS_THREAD); + }); + + it('includes private threads the viewer moderates', async () => { + const s = await setup(); + const result = await search(s.owner, {context_guild_id: s.guildId}, {expected: 3}); + expect(new Set(result.messages.map((message) => message.channel_id))).toEqual( + new Set([s.channelId, s.publicThread.id, s.privateThread.id]), + ); + }); + + it('drops private threads from a timed out thread moderator who is not a member', async () => { + const s = await setup(); + const role = await threadsRequest(harness, s.owner.token) + .post(`/guilds/${s.guildId}/roles`) + .body({name: 'mods', permissions: ThreadPermissionFlags.MANAGE_THREADS.toString()}) + .execute(); + await addMemberRole(harness, s.owner.token, s.guildId, s.member.userId, role.id); + const moderated = await search(s.member, {context_guild_id: s.guildId}, {expected: 3}); + expect(new Set(moderated.messages.map((message) => message.channel_id))).toEqual( + new Set([s.channelId, s.publicThread.id, s.privateThread.id]), + ); + await search(s.member, {context_guild_id: s.guildId, channel_ids: [s.privateThread.id]}); + await threadsRequest(harness, s.owner.token) + .patch(`/guilds/${s.guildId}/members/${s.member.userId}`) + .body({communication_disabled_until: new Date(Date.now() + 10 * 60 * 1000).toISOString()}) + .expect(200) + .execute(); + const timedOut = await search(s.member, {context_guild_id: s.guildId}, {expected: 2}); + expect(new Set(timedOut.messages.map((message) => message.channel_id))).toEqual( + new Set([s.channelId, s.publicThread.id]), + ); + await search(s.member, {context_guild_id: s.guildId, channel_ids: [s.privateThread.id]}, {status: 403}); + }); + + it('narrows to an explicit thread and refuses a private thread the viewer cannot see', async () => { + const s = await setup(); + const result = await search(s.member, {context_guild_id: s.guildId, channel_ids: [s.publicThread.id]}); + expect(result.messages.map((message) => message.channel_id)).toEqual([s.publicThread.id]); + await search(s.member, {context_guild_id: s.guildId, channel_ids: [s.privateThread.id]}, {status: 403}); + }); + + it('extends cross-guild search with the same thread scope', async () => { + const s = await setup(); + const viewer = await search(s.member, {scope: 'all_guilds'}, {expected: 2}); + expect(new Set(viewer.messages.map((message) => message.channel_id))).toEqual( + new Set([s.channelId, s.publicThread.id]), + ); + const control = await search(s.member, {scope: 'all_guilds'}, {capable: false}); + expect(control.messages.every((message) => message.channel_id === s.channelId)).toBe(true); + expect(control).not.toHaveProperty('threads'); + }); + + it('keeps control searches identical to a guild without threads', async () => { + const s = await setup(); + const result = await search(s.member, {context_guild_id: s.guildId}, {capable: false}); + expect(result.messages.every((message) => message.channel_id === s.channelId)).toBe(true); + expect(result).not.toHaveProperty('threads'); + expect(result).not.toHaveProperty('members'); + for (const message of result.messages) { + expect(message.flags & ServerMessageFlags.HAS_THREAD).toBe(0); + } + await search( + s.member, + {context_guild_id: s.guildId, channel_ids: [s.publicThread.id]}, + {capable: false, status: 400, code: 'INVALID_FORM_BODY'}, + ); + }); + it('never hands an indexed thread created notice to a control search', async () => { + const s = await setup(); + const [notice] = (await new ChannelRepository().listMessages(createChannelID(BigInt(s.channelId)))).filter( + (message) => message.type === MessageTypes.THREAD_CREATED, + ); + if (!notice) expect.fail('Expected a thread created notice'); + await getMessageSearchService()!.indexMessage(notice); + const viewer = await search(s.owner, {context_guild_id: s.guildId, content: 'topic'}); + expect(viewer.messages.map((message) => message.id)).toEqual([notice.id.toString()]); + const control = await search( + s.owner, + {context_guild_id: s.guildId, content: 'topic'}, + {capable: false, expected: 0}, + ); + expect(control.messages).toEqual([]); + }); +}); diff --git a/fluxer_api/src/api/search/tests/ThreadSearchEndpoint.test.ts b/fluxer_api/src/api/search/tests/ThreadSearchEndpoint.test.ts new file mode 100644 index 000000000..979d38e4e --- /dev/null +++ b/fluxer_api/src/api/search/tests/ThreadSearchEndpoint.test.ts @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {getSnowflakeService} from '@app/api/middleware/ServiceRegistry'; +import {getMessageSearchService, getThreadSearchService} from '@app/api/SearchFactory'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; +import {NoopWorkerService} from '@app/api/test/NoopWorkerService'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import refreshSearchIndex from '@app/api/worker/tasks/RefreshSearchIndex'; +import {backfillThreadSearch, syncThreadSearchDocument} from '@app/api/worker/tasks/ThreadSearchTasks'; +import {clearWorkerDependencies, setWorkerDependencies} from '@app/api/worker/WorkerContext'; +import {initializeWorkerDependencies} from '@app/api/worker/WorkerDependencies'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type { + StartForumThreadResponse, + ThreadSearchResponse, +} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import {createSnowflake, MAX_WORKER_ID, snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; +import type {WorkerTaskHandler, WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +const HANDLERS: Record = { + syncThreadSearchDocument, + backfillThreadSearch, + refreshSearchIndex, +}; + +function helpers(): WorkerTaskHelpers { + return { + logger: new NoopLogger(), + jobId: 1n, + addJob: async () => 0n, + reportProgress: async () => {}, + shouldCancel: async () => false, + setContextLink: async () => {}, + attempt: {isLastAttempt: true}, + }; +} + +interface Setup { + owner: TestAccount; + member: TestAccount; + guildId: string; + forumId: string; + textId: string; +} + +describe('thread search', () => { + let harness: ApiTestHarness; + let pending: Array>; + + beforeAll(async () => { + harness = await createApiTestHarness({search: 'enabled'}); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + setWorkerDependencies(await initializeWorkerDependencies(getSnowflakeService())); + pending = []; + vi.spyOn(NoopWorkerService.prototype, 'addJob').mockImplementation((async ( + task: string, + payload: Record, + ) => { + const handler = HANDLERS[task]; + if (handler) pending.push(handler(payload, helpers())); + return 0n; + }) as () => Promise); + await getThreadSearchService()!.deleteAllDocuments(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + clearWorkerDependencies(); + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function settle(): Promise { + while (pending.length > 0) await Promise.all(pending.splice(0)); + } + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + await ensureSessionStarted(harness, owner.token); + await ensureSessionStarted(harness, member.token); + const guild = await createGuild(harness, owner.token, 'search'); + const text = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, text.id); + await acceptInvite(harness, member.token, invite.code); + const forum = await threadsRequest(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'forum', type: ChannelTypes.GUILD_FORUM, available_tags: [{name: 'a'}, {name: 'b'}]}) + .execute(); + return {owner, member, guildId: guild.id, forumId: forum.id, textId: text.id}; + } + + async function post(s: Setup, name: string, appliedTags: Array = []): Promise { + return threadsRequest(harness, s.member.token) + .post(`/channels/${s.forumId}/threads`) + .body({name, applied_tags: appliedTags, message: {content: `${name} body`}}) + .expect(201) + .execute(); + } + + async function search(s: Setup, channelId: string, query = ''): Promise { + return threadsRequest(harness, s.member.token) + .get(`/channels/${channelId}/threads/search${query}`) + .execute(); + } + + it('answers 202 until the guild is backfilled, then searches', async () => { + const s = await setup(); + const created = await post(s, 'first post'); + await settle(); + const response = await harness.requestJson({ + path: `/channels/${s.forumId}/threads/search`, + headers: {Authorization: s.member.token, 'X-Fluxer-Features': 'channel_threads'}, + }); + expect(response.status).toBe(202); + expect(await response.json()).toMatchObject({ + code: APIErrorCodes.SEARCH_INDEX_NOT_READY, + documents_indexed: 0, + retry_after: 2, + }); + await settle(); + const result = await search(s, s.forumId); + expect(result.threads.map((thread) => thread.id)).toEqual([created.id]); + expect(result.total_results).toBe(1); + expect(result.has_more).toBe(false); + expect(result.first_messages?.map((message) => message.id)).toEqual([created.id]); + expect(result.members.map((member) => member.id)).toEqual([created.id]); + }); + + async function refreshThreadsIndex( + guildId: string, + status: number, + code?: string, + ): Promise<{errors?: Array<{path: string}>}> { + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']); + return createBuilder<{errors?: Array<{path: string}>}>(harness, admin.token) + .post('/admin/search/indexes/threads/refreshes') + .body({guild_id: guildId}) + .expect(status, code) + .execute(); + } + + it('rebuilds a dropped index through the admin threads reindex', async () => { + const s = await setup(); + const created = await post(s, 'kept post'); + await search(s, s.forumId).catch(() => null); + await settle(); + await getThreadSearchService()!.deleteAllDocuments(); + expect((await search(s, s.forumId)).threads).toEqual([]); + await refreshThreadsIndex(s.guildId, HTTP_STATUS.OK); + await settle(); + expect((await search(s, s.forumId)).threads.map((thread) => thread.id)).toEqual([created.id]); + }); + + it('rejects the threads reindex type while the experiment is off', async () => { + const s = await setup(); + await settle(); + resetChannelThreadsConfig(); + const response = await refreshThreadsIndex(s.guildId, HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY'); + expect(response.errors?.[0]?.path).toBe('index_name'); + expect(pending).toEqual([]); + }); + + it('keeps thread message documents when a message reindex runs while the guild is inactive', async () => { + const s = await setup(); + const thread = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.textId}/threads`) + .body({name: 'kept', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + await settle(); + await setChannelThreadsConfig({enabled: false}); + const deleted = vi.spyOn(getMessageSearchService()!, 'deleteChannelMessages'); + const queued: Array = []; + await refreshSearchIndex( + {index_type: 'channel_messages', job_id: '1', admin_user_id: s.owner.userId, guild_id: s.guildId}, + { + ...helpers(), + addJob: async (_task: string, payload: Record) => { + queued.push(String(payload.channelId)); + return 0n; + }, + } as WorkerTaskHelpers, + ); + const deletedIds = deleted.mock.calls.map(([channelId]) => channelId.toString()); + expect(deletedIds).toContain(s.textId); + expect(deletedIds).not.toContain(thread.id); + expect(deletedIds.sort()).toEqual(queued.sort()); + }); + + it('filters by name, tags and archive state and pages results', async () => { + const s = await setup(); + const forum = await threadsRequest(harness, s.owner.token).get(`/channels/${s.forumId}`).execute(); + const [a, b] = forum.available_tags!; + const apple = await post(s, 'apple pie', [a!.id]); + const banana = await post(s, 'banana bread', [a!.id, b!.id]); + const cherry = await post(s, 'cherry tart', [b!.id]); + await threadsRequest(harness, s.owner.token).patch(`/channels/${cherry.id}`).body({archived: true}).execute(); + await search(s, s.forumId).catch(() => null); + await settle(); + expect((await search(s, s.forumId, '?name=banana')).threads.map((thread) => thread.id)).toEqual([banana.id]); + const some = await search(s, s.forumId, `?tag=${a!.id}&tag=${b!.id}&sort_by=creation_time&sort_order=asc`); + expect(some.threads.map((thread) => thread.id)).toEqual([apple.id, banana.id, cherry.id]); + const all = await search(s, s.forumId, `?tag=${a!.id}&tag=${b!.id}&tag_setting=match_all`); + expect(all.threads.map((thread) => thread.id)).toEqual([banana.id]); + const archived = await search(s, s.forumId, '?archived=true'); + expect(archived.threads.map((thread) => thread.id)).toEqual([cherry.id]); + const page = await search(s, s.forumId, '?sort_by=creation_time&limit=2'); + expect(page.threads.map((thread) => thread.id)).toEqual([cherry.id, banana.id]); + expect(page.has_more).toBe(true); + expect(page.total_results).toBe(3); + }); + + it('ignores deleted tags in results and filters', async () => { + const s = await setup(); + const forum = await threadsRequest(harness, s.owner.token).get(`/channels/${s.forumId}`).execute(); + const [a, b] = forum.available_tags!; + const tagged = await post(s, 'tagged', [a!.id, b!.id]); + await search(s, s.forumId).catch(() => null); + await settle(); + await threadsRequest(harness, s.owner.token).delete(`/channels/${s.forumId}/tags/${a!.id}`).execute(); + const all = await search(s, s.forumId); + expect(all.threads.map((thread) => thread.applied_tags)).toEqual([[b!.id]]); + expect((await search(s, s.forumId, `?tag=${a!.id}`)).threads).toEqual([]); + expect((await search(s, s.forumId, `?tag=${a!.id}&tag=${b!.id}&tag_setting=match_all`)).threads).toEqual([]); + const some = await search(s, s.forumId, `?tag=${a!.id}&tag=${b!.id}`); + expect(some.threads.map((thread) => thread.id)).toEqual([tagged.id]); + }); + + it('keeps documents in sync with renames and deletes', async () => { + const s = await setup(); + const created = await post(s, 'old name'); + await search(s, s.forumId).catch(() => null); + await settle(); + await threadsRequest(harness, s.owner.token).patch(`/channels/${created.id}`).body({name: 'new name'}).execute(); + await settle(); + expect((await search(s, s.forumId, '?name=new')).threads.map((thread) => thread.id)).toEqual([created.id]); + expect((await search(s, s.forumId, '?name=old')).threads).toEqual([]); + await threadsRequest(harness, s.owner.token).delete(`/channels/${created.id}`).expect(204).execute(); + await settle(); + expect((await search(s, s.forumId)).threads).toEqual([]); + }); + + it('searches text channel threads without first messages and hides private threads from non-moderators', async () => { + const s = await setup(); + const open = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.textId}/threads`) + .body({name: 'open', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.textId}/threads`) + .body({name: 'secret', type: ChannelTypes.PRIVATE_THREAD}) + .expect(201) + .execute(); + const joined = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.textId}/threads`) + .body({name: 'joined', type: ChannelTypes.PRIVATE_THREAD}) + .expect(201) + .execute(); + await threadsRequest(harness, s.owner.token) + .put(`/channels/${joined.id}/thread-members/${s.member.userId}`) + .expect(204) + .execute(); + await search(s, s.textId).catch(() => null); + await settle(); + const result = await search(s, s.textId, '?sort_by=creation_time&sort_order=asc'); + expect(result.threads.map((thread) => thread.id)).toEqual([open.id, joined.id]); + expect(result.total_results).toBe(2); + expect(result).not.toHaveProperty('first_messages'); + const moderator = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.textId}/threads/search`) + .execute(); + expect(moderator.threads).toHaveLength(3); + }); + it('pages text channel threads by thread id, including threads started from older messages', async () => { + const s = await setup(); + const source = await sendMessage(harness, s.owner.token, s.textId, 'source'); + const standalone = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.textId}/threads`) + .body({name: 'standalone', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + const fromMessage = await threadsRequest(harness, s.owner.token) + .post(`/channels/${s.textId}/messages/${source.id}/threads`) + .body({name: 'from message'}) + .expect(201) + .execute(); + await search(s, s.textId).catch(() => null); + await settle(); + const ordered = await search(s, s.textId, '?sort_by=creation_time&sort_order=asc'); + expect(ordered.threads.map((thread) => thread.id)).toEqual([fromMessage.id, standalone.id]); + const before = await search(s, s.textId, `?sort_by=creation_time&max_id=${standalone.id}`); + expect(before.threads.map((thread) => thread.id)).toEqual([fromMessage.id]); + const after = await search(s, s.textId, `?sort_by=creation_time&min_id=${fromMessage.id}`); + expect(after.threads.map((thread) => thread.id)).toEqual([standalone.id]); + }); + + it('pages threads created in the same millisecond by thread id', async () => { + const s = await setup(); + const snowflakes = getSnowflakeService(); + const timestamp = Date.now() - 1000; + let sequence = 0; + const sameMillisecond = async () => + createSnowflake({timestamp, workerId: Number(MAX_WORKER_ID), sequence: sequence++}); + vi.spyOn(snowflakes, 'generate').mockImplementation(sameMillisecond); + vi.spyOn(snowflakes, 'generateForChannel').mockImplementation(sameMillisecond); + const first = await post(s, 'first'); + const second = await post(s, 'second'); + const third = await post(s, 'third'); + vi.mocked(snowflakes.generate).mockRestore(); + vi.mocked(snowflakes.generateForChannel).mockRestore(); + await search(s, s.forumId).catch(() => null); + await settle(); + const ids = [first.id, second.id, third.id]; + expect(new Set(ids.map((id) => snowflakeToDate(BigInt(id)).getTime())).size).toBe(1); + const pages: Array = []; + let maxId: string | null = null; + for (let i = 0; i < 3; i++) { + const page = await search(s, s.forumId, `?sort_by=creation_time&limit=1${maxId ? `&max_id=${maxId}` : ''}`); + pages.push(...page.threads.map((thread) => thread.id)); + maxId = page.threads.at(-1)?.id ?? null; + } + expect(pages).toEqual([third.id, second.id, first.id]); + const after = await search(s, s.forumId, `?sort_by=creation_time&sort_order=asc&min_id=${first.id}`); + expect(after.threads.map((thread) => thread.id)).toEqual([second.id, third.id]); + const offsetPages = await Promise.all( + [0, 1, 2].map((offset) => search(s, s.forumId, `?sort_by=last_message_time&limit=1&offset=${offset}`)), + ); + expect(new Set(offsetPages.flatMap((page) => page.threads.map((thread) => thread.id))).size).toBe(3); + }); +}); diff --git a/fluxer_api/src/api/search/tests/ThreadSearchScopeCap.test.ts b/fluxer_api/src/api/search/tests/ThreadSearchScopeCap.test.ts new file mode 100644 index 000000000..2a6400e7d --- /dev/null +++ b/fluxer_api/src/api/search/tests/ThreadSearchScopeCap.test.ts @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted, markGuildChannelsAsIndexed, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {TEST_TIMEOUTS, wait} from '@app/api/test/TestConstants'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +vi.mock('@fluxer/constants/src/ThreadConstants', async (importOriginal) => ({ + ...(await importOriginal()), + THREAD_SCOPE_MAX: 1, +})); + +interface SearchResult { + messages: Array<{channel_id: string}>; +} + +describe('thread search scope cap', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness({search: 'enabled'}); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + it('caps the thread scope across every guild of a global search', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + await ensureSessionStarted(harness, owner.token); + const channelIds = new Set(); + const threadIds = new Set(); + for (const name of ['first', 'second']) { + const guild = await createGuild(harness, owner.token, name); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + await markGuildChannelsAsIndexed(harness, owner.token, guild.id); + const thread = await threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'topic', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + await sendMessage(harness, owner.token, channel.id, 'needle channel'); + await threadsRequest(harness, owner.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'needle thread'}) + .execute(); + channelIds.add(channel.id); + threadIds.add(thread.id); + } + let result: SearchResult = {messages: []}; + for (let attempt = 0; attempt < 20; attempt++) { + result = await threadsRequest(harness, owner.token) + .post('/search/messages') + .body({content: 'needle', scope: 'all_guilds'}) + .expect(200) + .execute(); + if (result.messages.length >= 3) break; + await wait(TEST_TIMEOUTS.QUICK); + } + const searched = new Set(result.messages.map((message) => message.channel_id)); + expect([...channelIds].every((id) => searched.has(id))).toBe(true); + expect([...threadIds].filter((id) => searched.has(id))).toEqual([[...threadIds][1]]); + }); + + it('keeps the newest thread of the guild when the cap drops older ones in earlier channels', async () => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + await ensureSessionStarted(harness, owner.token); + const guild = await createGuild(harness, owner.token, 'spread'); + const first = await createChannel(harness, owner.token, guild.id, 'first'); + const second = await createChannel(harness, owner.token, guild.id, 'second'); + await markGuildChannelsAsIndexed(harness, owner.token, guild.id); + const threadIds: Array = []; + for (const channel of [first, second]) { + const thread = await threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'topic', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + await threadsRequest(harness, owner.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'needle thread'}) + .execute(); + threadIds.push(thread.id); + } + let result: SearchResult = {messages: []}; + for (let attempt = 0; attempt < 20; attempt++) { + result = await threadsRequest(harness, owner.token) + .post('/search/messages') + .body({content: 'needle', context_guild_id: guild.id}) + .expect(200) + .execute(); + if (result.messages.length >= 1) break; + await wait(TEST_TIMEOUTS.QUICK); + } + expect(result.messages.map((message) => message.channel_id)).toEqual([threadIds[1]]); + }); + + it.each([ + ['guild', (guildId: string) => ({context_guild_id: guildId})], + ['all_guilds', () => ({scope: 'all_guilds'})], + ['all', () => ({scope: 'all'})], + ] as const)('searches an explicitly named older thread past the scope cap in %s search', async (_scope, body) => { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + await ensureSessionStarted(harness, owner.token); + const guild = await createGuild(harness, owner.token, 'capped'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + await markGuildChannelsAsIndexed(harness, owner.token, guild.id); + const startThread = (type: number) => + threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'topic', type}) + .expect(201) + .execute(); + const older = await startThread(ChannelTypes.PRIVATE_THREAD); + await startThread(ChannelTypes.PUBLIC_THREAD); + await threadsRequest(harness, owner.token) + .post(`/channels/${older.id}/messages`) + .body({content: 'needle older'}) + .execute(); + let result: SearchResult = {messages: []}; + for (let attempt = 0; attempt < 20; attempt++) { + result = await threadsRequest(harness, owner.token) + .post('/search/messages') + .body({content: 'needle', ...body(guild.id), channel_ids: [older.id]}) + .expect(200) + .execute(); + if (result.messages.length >= 1) break; + await wait(TEST_TIMEOUTS.QUICK); + } + expect(result.messages.map((message) => message.channel_id)).toEqual([older.id]); + }); +}); diff --git a/fluxer_api/src/api/search/thread/ThreadSearchIndex.ts b/fluxer_api/src/api/search/thread/ThreadSearchIndex.ts new file mode 100644 index 000000000..66a737f2c --- /dev/null +++ b/fluxer_api/src/api/search/thread/ThreadSearchIndex.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {IThreadSearchService} from '@app/api/search/IThreadSearchService'; +import {SearchAdapterServiceBase} from '@app/api/search/SearchAdapterServiceBase'; +import type {ISearchAdapter} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes'; +import type {SearchableThread, ThreadSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes'; + +export class ThreadSearchIndex + extends SearchAdapterServiceBase< + ThreadSearchFilters, + SearchableThread, + ISearchAdapter + > + implements IThreadSearchService +{ + private initializing: Promise | null = null; + + constructor(adapter: ISearchAdapter) { + super(adapter); + } + + ready(): Promise { + if (this.isAvailable()) return Promise.resolve(); + this.initializing ??= this.initialize().catch((error) => { + this.initializing = null; + throw error; + }); + return this.initializing; + } +} diff --git a/fluxer_api/src/api/search/thread/ThreadSearchService.ts b/fluxer_api/src/api/search/thread/ThreadSearchService.ts new file mode 100644 index 000000000..d84d0b145 --- /dev/null +++ b/fluxer_api/src/api/search/thread/ThreadSearchService.ts @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type ChannelID, createChannelID, type GuildID} from '@app/api/BrandedTypes'; +import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate'; +import type {ThreadView} from '@app/api/channel/services/thread/ThreadMappers'; +import {loadThreadViews} from '@app/api/channel/services/thread/ThreadViews'; +import {guildActive} from '@app/api/experiment/ChannelThreadsGate'; +import {Logger} from '@app/api/Logger'; +import {getThreadSearchService} from '@app/api/SearchFactory'; +import type {IThreadSearchService} from '@app/api/search/IThreadSearchService'; +import type {SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes'; +import type { + SearchableThread, + ThreadSearchCursor, + ThreadSearchFilters, +} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes'; +import {snowflakeToDate, TIMESTAMP_SHIFT} from '@fluxer/snowflake/src/Snowflake'; + +const BACKFILL_BATCH_SIZE = 100; + +function millis(id: bigint | null | undefined, fallback: Date): number { + return id != null ? snowflakeToDate(id).getTime() : fallback.getTime(); +} + +export function threadSearchCursor(id: bigint): ThreadSearchCursor { + return {createdAt: snowflakeToDate(id).getTime(), idSequence: Number(id & ((1n << TIMESTAMP_SHIFT) - 1n))}; +} + +export function toSearchableThread(view: ThreadView): SearchableThread { + const {channel, state} = view; + return { + id: channel.id.toString(), + guildId: state.guildId.toString(), + parentId: state.parentId.toString(), + type: channel.type, + name: channel.name ?? '', + ownerId: channel.ownerId?.toString() ?? null, + archived: state.archived, + locked: state.locked, + appliedTagIds: state.appliedTags.map((tag) => tag.toString()), + ...threadSearchCursor(channel.id), + lastMessageAt: millis(channel.lastMessageId, state.createdAt), + archivedAt: (state.archiveTimestamp ?? state.createdAt).getTime(), + }; +} + +async function readyService(): Promise { + const service = getThreadSearchService(); + if (!service) return null; + await service.ready(); + return service; +} + +export async function syncThreadSearchDocument( + repository: IChannelRepositoryAggregate, + threadId: ChannelID, +): Promise { + const service = await readyService(); + if (!service) return; + const state = await repository.threads.getState(threadId); + if (state && !guildActive(state.guildId)) return; + const [view] = state ? await loadThreadViews(repository, [state]) : []; + if (!view) { + await service.deleteDocument(threadId.toString()); + return; + } + await service.indexDocument(toSearchableThread(view)); +} + +export async function deleteThreadSearchDocuments(threadIds: ReadonlyArray): Promise { + if (threadIds.length === 0) return; + try { + const service = await readyService(); + if (!service) return; + await service.deleteDocuments(threadIds.map((id) => id.toString())); + } catch (error) { + Logger.warn({error, count: threadIds.length}, 'Failed to delete thread search documents'); + } +} + +export async function backfillThreadSearch(repository: IChannelRepositoryAggregate, guildId: GuildID): Promise { + const service = await readyService(); + if (!service || !guildActive(guildId)) return 0; + const threadIds = await repository.threads.listGuildThreadIds(guildId); + let indexed = 0; + for (let offset = 0; offset < threadIds.length; offset += BACKFILL_BATCH_SIZE) { + const states = await repository.threads.getStates(threadIds.slice(offset, offset + BACKFILL_BATCH_SIZE)); + const views = await loadThreadViews(repository, states); + await service.indexDocuments(views.map(toSearchableThread)); + indexed += views.length; + } + await service.refreshIndex(); + await repository.threads.markGuildSearchBackfilled(guildId, new Date()); + return indexed; +} + +export async function searchThreadDocuments(params: { + name: string; + filters: ThreadSearchFilters; + limit: number; + offset: number; +}): Promise | null> { + const service = await readyService(); + if (!service) return null; + return service.search(params.name, params.filters, {limit: params.limit, offset: params.offset}); +} + +export function searchHitThreadIds(result: SearchResult): Array { + return result.hits.map((hit) => createChannelID(BigInt(hit.id))); +} diff --git a/fluxer_api/src/api/test/ApiTestHarness.ts b/fluxer_api/src/api/test/ApiTestHarness.ts index 0df020a8e..9dea22cf4 100644 --- a/fluxer_api/src/api/test/ApiTestHarness.ts +++ b/fluxer_api/src/api/test/ApiTestHarness.ts @@ -57,6 +57,7 @@ export interface ApiTestHarness { interface CreateApiTestHarnessOptions { search?: 'disabled' | 'enabled'; + registerRoutes?: (routes: HonoApp) => void; } export async function createApiTestHarness(options: CreateApiTestHarnessOptions = {}): Promise { @@ -92,6 +93,7 @@ export async function createApiTestHarness(options: CreateApiTestHarnessOptions } = await createAPIApp({ config: Config, logger: harnessLogger, + registerRoutes: options.registerRoutes, }); try { await initializeApp(); diff --git a/fluxer_api/src/api/test/NoopGatewayService.ts b/fluxer_api/src/api/test/NoopGatewayService.ts index d26896b8e..3ef70d399 100644 --- a/fluxer_api/src/api/test/NoopGatewayService.ts +++ b/fluxer_api/src/api/test/NoopGatewayService.ts @@ -9,6 +9,7 @@ import { type UserID, } from '@app/api/BrandedTypes'; import type {GatewayDispatchEvent} from '@app/api/constants/Gateway'; +import {guildActive} from '@app/api/experiment/ChannelThreadsGate'; import { mapGuildEmojiToResponse, mapGuildRoleToResponse, @@ -34,10 +35,15 @@ import { import {UserRepository} from '@app/api/user/repositories/UserRepository'; import {mapUserToPartialResponse} from '@app/api/user/UserMappers'; import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_AWARE_ALL_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; +function allPermissionsFor(guildId: GuildID): bigint { + return guildActive(guildId) ? THREAD_AWARE_ALL_PERMISSIONS : ALL_PERMISSIONS; +} + const guildOwners = new Map(); const guildMembers = new Map>(); const guildRepository = new GuildRepository(); @@ -125,6 +131,15 @@ export class NoopGatewayService extends IGatewayService { async dispatchGuild(_params: {guildId: GuildID; event: GatewayDispatchEvent; data: unknown}): Promise {} + async dispatchGuildMany(params: { + guildId: GuildID; + events: Array<{event: GatewayDispatchEvent; data: unknown}>; + }): Promise { + for (const {event, data} of params.events) { + await this.dispatchGuild({guildId: params.guildId, event, data}); + } + } + async getGuildCounts(guildId: GuildID): Promise<{ memberCount: number; presenceCount: number; @@ -250,7 +265,7 @@ export class NoopGatewayService extends IGatewayService { const basePermissions = this.calculateGuildPermissions(new Set(), roles, params.guildId); const {ChannelDataRepository} = await import('@app/api/channel/repositories/ChannelDataRepository'); const channelRepo = new ChannelDataRepository(); - const channels = await channelRepo.listGuildChannels(params.guildId); + const channels = await channelRepo.listGuildChannels(params.guildId, 'enrolled'); const channelsById = new Map(channels.map((channel) => [channel.id, channel])); const result: Array = []; const seen = new Set(); @@ -273,12 +288,13 @@ export class NoopGatewayService extends IGatewayService { async getUserPermissions(params: {guildId: GuildID; userId: UserID; channelId?: ChannelID}): Promise { const {guildId, userId, channelId} = params; + await this.assertNotThreadChannel(channelId); const guild = await guildRepository.findUnique(guildId); if (!guild) { return 0n; } if (guild.ownerId === userId) { - return ALL_PERMISSIONS; + return allPermissionsFor(guildId); } const member = await guildMemberRepository.getMember(guildId, userId); if (!member) { @@ -298,14 +314,24 @@ export class NoopGatewayService extends IGatewayService { return this.applyChannelOverwrites(guildPermissions, member.roleIds, channel, userId, guildId); } - async getUserPermissionsBatch(_params: { + async getUserPermissionsBatch(params: { guildIds: Array; userId: UserID; channelId?: ChannelID; }): Promise> { + await this.assertNotThreadChannel(params.channelId); return new Map(); } + private async assertNotThreadChannel(channelId: ChannelID | undefined): Promise { + if (!channelId) return; + const {ChannelDataRepository} = await import('@app/api/channel/repositories/ChannelDataRepository'); + const channel = await new ChannelDataRepository().findUnique(channelId); + if (channel?.isThread()) { + throw new Error(`Thread channel ${channelId} reached a gateway permission call`); + } + } + async canManageRoles(params: { guildId: GuildID; userId: UserID; @@ -460,7 +486,7 @@ export class NoopGatewayService extends IGatewayService { const guild = await guildRepository.findUnique(guildId); const {ChannelDataRepository} = await import('@app/api/channel/repositories/ChannelDataRepository'); const channelRepo = new ChannelDataRepository(); - const channels = await channelRepo.listGuildChannels(guildId); + const channels = await channelRepo.listGuildChannels(guildId, 'enrolled'); if (guild?.ownerId === userId) { return channels.map((ch) => ch.id); } @@ -523,7 +549,7 @@ export class NoopGatewayService extends IGatewayService { ]); const {ChannelDataRepository} = await import('@app/api/channel/repositories/ChannelDataRepository'); const channelRepo = new ChannelDataRepository(); - const allChannels = await channelRepo.listGuildChannels(params.guildId); + const allChannels = await channelRepo.listGuildChannels(params.guildId, 'enrolled'); const viewableChannelIdSet = new Set( params.skipMembershipCheck ? allChannels.map((channel) => channel.id) : viewableChannelIds, ); @@ -651,6 +677,7 @@ export class NoopGatewayService extends IGatewayService { channelId?: ChannelID; }): Promise { const {guildId, userId, permission, channelId} = params; + await this.assertNotThreadChannel(channelId); const guild = await guildRepository.findUnique(guildId); if (!guild) { return false; @@ -698,7 +725,7 @@ export class NoopGatewayService extends IGatewayService { if (role) { permissions |= role.permissions; if ((permissions & Permissions.ADMINISTRATOR) !== 0n) { - return ALL_PERMISSIONS; + return allPermissionsFor(guildId); } } } @@ -721,7 +748,7 @@ export class NoopGatewayService extends IGatewayService { guildId: GuildID, ): bigint { if ((basePermissions & Permissions.ADMINISTRATOR) !== 0n) { - return ALL_PERMISSIONS; + return allPermissionsFor(guildId); } if (!channel.permissionOverwrites) { return basePermissions; diff --git a/fluxer_api/src/api/test/search/InMemorySearchProvider.ts b/fluxer_api/src/api/test/search/InMemorySearchProvider.ts index ad84454b3..60635fef5 100644 --- a/fluxer_api/src/api/test/search/InMemorySearchProvider.ts +++ b/fluxer_api/src/api/test/search/InMemorySearchProvider.ts @@ -17,6 +17,7 @@ import type {IGuildSearchService} from '@app/api/search/IGuildSearchService'; import type {IMessageSearchService} from '@app/api/search/IMessageSearchService'; import type {IReportSearchService} from '@app/api/search/IReportSearchService'; import type {ISearchProvider} from '@app/api/search/ISearchProvider'; +import type {IThreadSearchService} from '@app/api/search/IThreadSearchService'; import type {IUserSearchService} from '@app/api/search/IUserSearchService'; import {convertToSearchableMessage} from '@app/api/search/message/MessageSearchSerializer'; import {convertToSearchableReport} from '@app/api/search/report/ReportSearchSerializer'; @@ -33,7 +34,10 @@ import type { SearchableGuildMember, SearchableMessage, SearchableReport, + SearchableThread, SearchableUser, + ThreadSearchCursor, + ThreadSearchFilters, UserSearchFilters, } from '@fluxer/schema/src/contracts/search/SearchDocumentTypes'; @@ -689,6 +693,60 @@ class InMemoryGuildMemberSearchService } } +function compareThreadCursor(doc: SearchableThread, cursor: ThreadSearchCursor): number { + return doc.createdAt - cursor.createdAt || doc.idSequence - cursor.idSequence; +} + +function matchesThreadFilters(doc: SearchableThread, filters: ThreadSearchFilters): boolean { + if (doc.guildId !== filters.guildId || doc.parentId !== filters.parentId) return false; + if (filters.publicOnly && doc.type === 12 && !filters.privateThreadIds?.includes(doc.id)) return false; + if (filters.archived !== undefined && doc.archived !== filters.archived) return false; + if (filters.tagIds && filters.tagIds.length > 0) { + const matches = + filters.tagSetting === 'match_all' + ? filters.tagIds.every((id) => doc.appliedTagIds.includes(id)) + : filters.tagIds.some((id) => doc.appliedTagIds.includes(id)); + if (!matches) return false; + } + if (filters.after && compareThreadCursor(doc, filters.after) <= 0) return false; + if (filters.before && compareThreadCursor(doc, filters.before) >= 0) return false; + return true; +} + +const THREAD_SORT_FIELDS = { + last_message_time: 'lastMessageAt', + archive_time: 'archivedAt', + creation_time: 'createdAt', +} as const; + +function sortThreadDocs( + left: SearchableThread, + right: SearchableThread, + filters: ThreadSearchFilters, + query: string, +): number { + const sortBy = filters.sortBy ?? 'last_message_time'; + if (sortBy === 'relevance') { + const delta = relevanceScore([right.name], query) - relevanceScore([left.name], query); + if (delta !== 0) return delta; + return Number(BigInt(right.id) - BigInt(left.id)); + } + const field = THREAD_SORT_FIELDS[sortBy]; + const direction = filters.sortOrder === 'asc' ? 1 : -1; + return (left[field] - right[field] || Number(BigInt(left.id) - BigInt(right.id))) * direction; +} + +class InMemoryThreadSearchService + extends InMemorySearchServiceBase + implements IThreadSearchService +{ + constructor() { + super(matchesThreadFilters, (doc) => [doc.name], sortThreadDocs); + } + + async ready(): Promise {} +} + export class InMemorySearchProvider implements ISearchProvider { private readonly messages = new InMemoryMessageSearchService(); private readonly guilds = new InMemoryGuildSearchService(); @@ -696,6 +754,7 @@ export class InMemorySearchProvider implements ISearchProvider { private readonly reports = new InMemoryReportSearchService(); private readonly auditLogs = new InMemoryAuditLogSearchService(); private readonly guildMembers = new InMemoryGuildMemberSearchService(); + private readonly threads = new InMemoryThreadSearchService(); async initialize(): Promise { await Promise.all([ @@ -716,6 +775,7 @@ export class InMemorySearchProvider implements ISearchProvider { this.reports.shutdown(), this.auditLogs.shutdown(), this.guildMembers.shutdown(), + this.threads.shutdown(), ]); } @@ -742,4 +802,8 @@ export class InMemorySearchProvider implements ISearchProvider { getGuildMemberSearchService(): IGuildMemberSearchService { return this.guildMembers; } + + getThreadSearchService(): IThreadSearchService { + return this.threads; + } } diff --git a/fluxer_api/src/api/types/HonoEnv.ts b/fluxer_api/src/api/types/HonoEnv.ts index f5a164802..1d28ff320 100644 --- a/fluxer_api/src/api/types/HonoEnv.ts +++ b/fluxer_api/src/api/types/HonoEnv.ts @@ -15,6 +15,7 @@ import type {ChannelService} from '@app/api/channel/services/ChannelService'; import type {MessageRequestService} from '@app/api/channel/services/message/MessageRequestService'; import type {StreamPreviewService} from '@app/api/channel/services/StreamPreviewService'; import type {StreamService} from '@app/api/channel/services/StreamService'; +import type {ThreadService} from '@app/api/channel/services/thread/ThreadService'; import type {ConnectionRequestService} from '@app/api/connection/ConnectionRequestService'; import type {ConnectionService} from '@app/api/connection/ConnectionService'; import type {NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService'; @@ -107,6 +108,7 @@ export interface HonoEnv { adminUserId: UserID; adminUserAcls: Set; authTokenType?: 'session' | 'bearer' | 'bot' | 'admin_api_key'; + clientFeatures: ReadonlySet; authViaCookie?: boolean; authToken?: string; authUserId?: string; @@ -123,6 +125,7 @@ export interface HonoEnv { cacheService: ICacheService; channelService: ChannelService; channelRequestService: ChannelRequestService; + threadService: ThreadService; messageRequestService: MessageRequestService; channelRepository: IChannelRepository; connectionService: ConnectionService; @@ -197,6 +200,7 @@ export interface HonoEnv { requestLocale: string; errorI18nService: ErrorI18nService; channelUpdateType?: number; + channelUpdateGuildId?: string; }; } diff --git a/fluxer_api/src/api/user/UserGuildSettingsThreadView.ts b/fluxer_api/src/api/user/UserGuildSettingsThreadView.ts new file mode 100644 index 000000000..ede266b83 --- /dev/null +++ b/fluxer_api/src/api/user/UserGuildSettingsThreadView.ts @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createGuildID, type GuildID, type UserID} from '@app/api/BrandedTypes'; +import {fetchMany} from '@app/api/database/CassandraQueryExecution'; +import type {ThreadOnlyChannelsByGuildRow} from '@app/api/database/types/ThreadTypes'; +import { + everEnabled, + isTainted, + type ThreadViewer, + viewerActive, + viewerFromCtx, +} from '@app/api/experiment/ChannelThreadsGate'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import type {UserGuildSettings} from '@app/api/models/UserGuildSettings'; +import {ThreadOnlyChannelsByGuild} from '@app/api/Tables'; +import type {HonoEnv} from '@app/api/types/HonoEnv'; +import {mapUserGuildSettingsToResponse, type UserGuildSettingsThreadView} from '@app/api/user/UserMappers'; +import type {UserGuildSettingsResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; +import type {Context} from 'hono'; + +const FETCH_THREAD_ONLY_CHANNEL_IDS_CQL = ThreadOnlyChannelsByGuild.selectCql({ + columns: ['channel_id'], + where: ThreadOnlyChannelsByGuild.where.eq('guild_id'), +}); + +const DM_SETTINGS_GUILD_ID = createGuildID(0n); + +export const SYSTEM_USER_GUILD_SETTINGS_VIEW: UserGuildSettingsThreadView = {flags: true}; + +export async function listThreadOnlyChannelIds(guildId: GuildID): Promise> { + const rows = await fetchMany>(FETCH_THREAD_ONLY_CHANNEL_IDS_CQL, { + guild_id: guildId, + }); + return new Set(rows.map((row) => row.channel_id.toString())); +} + +export async function controlUserGuildSettingsView( + settings: UserGuildSettings, +): Promise { + if (settings.guildId === DM_SETTINGS_GUILD_ID || settings.channelOverrides.size === 0 || !everEnabled()) { + return undefined; + } + if (!(await isTainted(settings.guildId))) return undefined; + const forumIds = await listThreadOnlyChannelIds(settings.guildId); + const hiddenChannelIds = new Set( + Array.from(settings.channelOverrides.keys()) + .map((channelId) => channelId.toString()) + .filter((channelId) => forumIds.has(channelId)), + ); + return hiddenChannelIds.size > 0 ? {flags: false, hiddenChannelIds} : undefined; +} + +export async function resolveUserGuildSettingsView( + settings: UserGuildSettings, + viewer: ThreadViewer, +): Promise { + if (viewer.kind === 'system') return SYSTEM_USER_GUILD_SETTINGS_VIEW; + if (settings.guildId !== DM_SETTINGS_GUILD_ID && viewerActive(viewer, settings.guildId)) { + return SYSTEM_USER_GUILD_SETTINGS_VIEW; + } + return controlUserGuildSettingsView(settings); +} + +export async function mapUserGuildSettingsForViewer( + settings: UserGuildSettings, + viewer: ThreadViewer, +): Promise { + return mapUserGuildSettingsToResponse(settings, await resolveUserGuildSettingsView(settings, viewer)); +} + +export async function dispatchUserGuildSettingsUpdate( + gatewayService: IGatewayService, + userId: UserID, + settings: UserGuildSettings, +): Promise { + const payload = mapUserGuildSettingsToResponse(settings, await controlUserGuildSettingsView(settings)); + if (payload.guild_id === null) { + await gatewayService.dispatchPresence({userId, event: 'USER_GUILD_SETTINGS_UPDATE', data: payload}); + await gatewayService.syncPushUserGuildSettings({userId, guildId: settings.guildId, settings: payload}); + return; + } + const full = mapUserGuildSettingsToResponse(settings, SYSTEM_USER_GUILD_SETTINGS_VIEW); + if (JSON.stringify(full) === JSON.stringify(payload)) { + await gatewayService.dispatchPresence({userId, event: 'USER_GUILD_SETTINGS_UPDATE', data: payload}); + } else { + await gatewayService.dispatchPresence({ + userId, + event: 'USER_GUILD_SETTINGS_UPDATE', + data: {...payload, __thread_unscoped: payload.guild_id}, + }); + await gatewayService.dispatchPresence({ + userId, + event: 'USER_GUILD_SETTINGS_UPDATE', + data: {...full, __thread_scoped: payload.guild_id}, + }); + } + await gatewayService.syncPushUserGuildSettings({userId, guildId: settings.guildId, settings: full}); +} + +export function gateChannelOverrideFlags(raw: unknown, ctx: Context): unknown { + const guildId = ctx.req.param('guild_id'); + return guildId !== undefined && viewerActive(viewerFromCtx(ctx), guildId) ? raw : stripChannelOverrideFlags(raw); +} + +function stripChannelOverrideFlags(raw: unknown): unknown { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw; + const overrides = (raw as {channel_overrides?: unknown}).channel_overrides; + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return raw; + const entries = Object.entries(overrides); + if (!entries.some(([, override]) => override !== null && typeof override === 'object' && 'flags' in override)) { + return raw; + } + return { + ...raw, + channel_overrides: Object.fromEntries( + entries.map(([channelId, override]) => { + if (override === null || typeof override !== 'object' || !('flags' in override)) return [channelId, override]; + const {flags: _flags, ...rest} = override as Record; + return [channelId, rest]; + }), + ), + }; +} diff --git a/fluxer_api/src/api/user/UserMappers.ts b/fluxer_api/src/api/user/UserMappers.ts index 071dab8e5..ebd38d132 100644 --- a/fluxer_api/src/api/user/UserMappers.ts +++ b/fluxer_api/src/api/user/UserMappers.ts @@ -390,7 +390,10 @@ const mapMuteConfigToResponse = ( } : null; -function mapChannelOverrideToResponse(override: GuildChannelOverride): { +function mapChannelOverrideToResponse( + override: GuildChannelOverride, + withFlags: boolean, +): { collapsed: boolean; message_notifications: ChannelMessageNotifications; muted: boolean; @@ -399,6 +402,7 @@ function mapChannelOverrideToResponse(override: GuildChannelOverride): { selected_time_window: number; } | null; unread_badges: ChannelMessageNotifications | null; + flags?: number; } { return { collapsed: override.collapsed, @@ -406,10 +410,23 @@ function mapChannelOverrideToResponse(override: GuildChannelOverride): { muted: override.muted, mute_config: mapMuteConfigToResponse(override.muteConfig), unread_badges: override.unreadBadges ?? null, + ...(withFlags && override.flags ? {flags: override.flags} : {}), }; } -export function mapUserGuildSettingsToResponse(settings: UserGuildSettings): UserGuildSettingsResponse { +export interface UserGuildSettingsThreadView { + flags: boolean; + hiddenChannelIds?: ReadonlySet; +} + +export function mapUserGuildSettingsToResponse( + settings: UserGuildSettings, + threadView?: UserGuildSettingsThreadView, +): UserGuildSettingsResponse { + const hidden = threadView?.hiddenChannelIds; + const overrides = hidden?.size + ? Array.from(settings.channelOverrides.entries()).filter(([channelId]) => !hidden.has(channelId.toString())) + : Array.from(settings.channelOverrides.entries()); return { guild_id: settings.guildId === createGuildID(0n) ? null : settings.guildId.toString(), message_notifications: settings.messageNotifications ?? 0, @@ -419,11 +436,11 @@ export function mapUserGuildSettingsToResponse(settings: UserGuildSettings): Use suppress_everyone: settings.suppressEveryone, suppress_roles: settings.suppressRoles, hide_muted_channels: settings.hideMutedChannels, - channel_overrides: settings.channelOverrides.size + channel_overrides: overrides.length ? Object.fromEntries( - Array.from(settings.channelOverrides.entries()).map(([channelId, override]) => [ + overrides.map(([channelId, override]) => [ channelId.toString(), - mapChannelOverrideToResponse(override), + mapChannelOverrideToResponse(override, threadView?.flags === true), ]), ) : null, diff --git a/fluxer_api/src/api/user/controllers/UserAccountController.ts b/fluxer_api/src/api/user/controllers/UserAccountController.ts index deb71fbdf..6b9ea34e7 100644 --- a/fluxer_api/src/api/user/controllers/UserAccountController.ts +++ b/fluxer_api/src/api/user/controllers/UserAccountController.ts @@ -5,6 +5,7 @@ import * as AuthSession from '@app/api/auth/AuthSession'; import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService'; import {createGuildID, createUserID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import { RequireEmailAccountIdentity, RequireUsernameAccountIdentity, @@ -19,12 +20,10 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig'; import type {HonoApp} from '@app/api/types/HonoEnv'; import {classifyWebPushOrigin} from '@app/api/user/services/WebPushOriginReplacement'; import {getCachedUserPartialResponse} from '@app/api/user/UserCacheHelpers'; +import {gateChannelOverrideFlags, mapUserGuildSettingsForViewer} from '@app/api/user/UserGuildSettingsThreadView'; import {isSsoUserWithoutPassword} from '@app/api/user/UserHelpers'; -import { - mapUserGuildSettingsToResponse, - mapUserSettingsToResponse, - mapUserToPrivateResponse, -} from '@app/api/user/UserMappers'; +import {mapUserSettingsToResponse, mapUserToPrivateResponse} from '@app/api/user/UserMappers'; +import {CHANNEL_THREADS_CLIENT_FEATURE} from '@app/api/utils/featureUtils'; import {Validator} from '@app/api/Validator'; import {UserFlags} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; @@ -802,7 +801,7 @@ export function UserAccountController(app: HonoApp) { RateLimitMiddleware(RateLimitConfigs.USER_GUILD_SETTINGS_UPDATE), LoginRequired, DefaultUserOnly, - Validator('json', UserGuildSettingsUpdateRequest), + Validator('json', UserGuildSettingsUpdateRequest, {pre: gateChannelOverrideFlags}), OpenAPI({ operationId: 'update_dm_notification_settings', summary: 'Update DM notification settings', @@ -819,7 +818,7 @@ export function UserAccountController(app: HonoApp) { guildId: null, data: ctx.req.valid('json'), }); - return ctx.json(mapUserGuildSettingsToResponse(settings)); + return ctx.json(await mapUserGuildSettingsForViewer(settings, viewerFromCtx(ctx))); }, ); app.patch( @@ -828,7 +827,7 @@ export function UserAccountController(app: HonoApp) { LoginRequired, DefaultUserOnly, Validator('param', GuildIdParam), - Validator('json', UserGuildSettingsUpdateRequest), + Validator('json', UserGuildSettingsUpdateRequest, {pre: gateChannelOverrideFlags}), OpenAPI({ operationId: 'update_guild_settings_for_user', summary: 'Update guild settings for user', @@ -841,12 +840,14 @@ export function UserAccountController(app: HonoApp) { }), async (ctx) => { const {guild_id} = ctx.req.valid('param'); + const viewer = viewerFromCtx(ctx); const settings = await ctx.get('userService').accountService.settingsService.updateGuildSettings({ userId: ctx.get('user').id, guildId: createGuildID(guild_id), data: ctx.req.valid('json'), + viewer, }); - return ctx.json(mapUserGuildSettingsToResponse(settings)); + return ctx.json(await mapUserGuildSettingsForViewer(settings, viewer)); }, ); app.post( @@ -930,6 +931,7 @@ export function UserAccountController(app: HonoApp) { userAgent: user_agent, originKind: classifyWebPushOrigin(ctx.req.header('origin'), Config.instance.selfHosted), installedApp: installed_app, + threadChannels: ctx.get('clientFeatures').has(CHANNEL_THREADS_CLIENT_FEATURE), }); return ctx.json({subscription_id: subscription.subscriptionId}); }, @@ -962,6 +964,7 @@ export function UserAccountController(app: HonoApp) { userAgent: user_agent, originKind: classifyWebPushOrigin(ctx.req.header('origin'), Config.instance.selfHosted), installedApp: installed_app, + threadChannels: ctx.get('clientFeatures').has(CHANNEL_THREADS_CLIENT_FEATURE), }); return ctx.json({subscription_id: subscription.subscriptionId}); }, @@ -1036,6 +1039,7 @@ export function UserAccountController(app: HonoApp) { userId: ctx.get('user').id, authSessionIdHash: authSession ? uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true}) : null, device, + threadChannels: ctx.get('clientFeatures').has(CHANNEL_THREADS_CLIENT_FEATURE), }); return ctx.json({device_id: subscription.subscriptionId}); }, @@ -1130,6 +1134,7 @@ export function UserAccountController(app: HonoApp) { const userAccountRequestService = ctx.get('userAccountRequestService'); return ctx.json( await userAccountRequestService.preloadMessages({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, channels: ctx.req.valid('json').channels, requestCache: ctx.get('requestCache'), @@ -1156,6 +1161,7 @@ export function UserAccountController(app: HonoApp) { const userAccountRequestService = ctx.get('userAccountRequestService'); return ctx.json( await userAccountRequestService.preloadMessages({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, channels: ctx.req.valid('json').channels, requestCache: ctx.get('requestCache'), diff --git a/fluxer_api/src/api/user/controllers/UserChannelController.ts b/fluxer_api/src/api/user/controllers/UserChannelController.ts index 4998d2757..61b02bae4 100644 --- a/fluxer_api/src/api/user/controllers/UserChannelController.ts +++ b/fluxer_api/src/api/user/controllers/UserChannelController.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createChannelID} from '@app/api/BrandedTypes'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {GroupDmCreateProtectionMiddleware} from '@app/api/middleware/GroupDmProtectionMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; @@ -82,6 +83,7 @@ export function UserChannelController(app: HonoApp) { }), async (ctx) => { await ctx.get('userChannelRequestService').pinChannel({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, channelId: createChannelID(ctx.req.valid('param').channel_id), }); @@ -105,6 +107,7 @@ export function UserChannelController(app: HonoApp) { }), async (ctx) => { await ctx.get('userChannelRequestService').unpinChannel({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, channelId: createChannelID(ctx.req.valid('param').channel_id), }); diff --git a/fluxer_api/src/api/user/controllers/UserContentController.ts b/fluxer_api/src/api/user/controllers/UserContentController.ts index f5db7a11d..91bb94969 100644 --- a/fluxer_api/src/api/user/controllers/UserContentController.ts +++ b/fluxer_api/src/api/user/controllers/UserContentController.ts @@ -2,6 +2,7 @@ import {Readable} from 'node:stream'; import {createChannelID, createMessageID} from '@app/api/BrandedTypes'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {StorageObjectRangeNotSatisfiableError} from '@app/api/infrastructure/IStorageService'; import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; @@ -47,6 +48,7 @@ export function UserContentController(app: HonoApp) { async (ctx) => { const {limit, roles, everyone, guilds, before} = ctx.req.valid('query'); const response = await ctx.get('userContentRequestService').listMentions({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, limit, everyone, @@ -126,6 +128,7 @@ export function UserContentController(app: HonoApp) { async (ctx) => { const {limit, before} = ctx.req.valid('query'); const response = await ctx.get('userContentRequestService').listSavedMessages({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, limit, before: before ? createMessageID(before) : undefined, @@ -153,6 +156,7 @@ export function UserContentController(app: HonoApp) { async (ctx) => { const {channel_id, message_id} = ctx.req.valid('json'); await ctx.get('userContentRequestService').saveMessage({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, channelId: createChannelID(channel_id), messageId: createMessageID(message_id), diff --git a/fluxer_api/src/api/user/repositories/PushSubscriptionRepository.test.ts b/fluxer_api/src/api/user/repositories/PushSubscriptionRepository.test.ts new file mode 100644 index 000000000..a2a896141 --- /dev/null +++ b/fluxer_api/src/api/user/repositories/PushSubscriptionRepository.test.ts @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createUserID} from '@app/api/BrandedTypes'; +import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution'; +import type {PreparedQuery} from '@app/api/database/CassandraTypes'; +import type {PushSubscriptionRow} from '@app/api/database/types/UserTypes'; +import {syncChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate'; +import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor'; +import {PushSubscriptionRepository} from '@app/api/user/repositories/PushSubscriptionRepository'; +import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +const USER_ID = createUserID(1000000000000000000n); + +class CountingExecutor extends InMemoryCassandraQueryExecutor { + selects = 0; + + override async executeQuery(query: PreparedQuery): Promise> { + if (/^\s*SELECT/i.test(query.cql)) this.selects++; + return super.executeQuery(query); + } +} + +let executor: CountingExecutor; + +function row(threadChannels?: boolean): PushSubscriptionRow { + return { + user_id: USER_ID, + subscription_id: 'device', + auth_session_id_hash: null, + endpoint: 'fcm:token', + p256dh_key: null, + auth_key: null, + user_agent: null, + platform: 'android_fcm', + app_id: null, + provider_environment: null, + ...(threadChannels === undefined ? {} : {thread_channels: threadChannels}), + }; +} + +function setEverEnabled(everEnabled: boolean) { + syncChannelThreadsConfig(JSON.stringify({ever_enabled: everEnabled}), (raw) => + ChannelThreadsConfigSchema.parse(JSON.parse(raw ?? '{}')), + ); +} + +async function storedCapability(repository: PushSubscriptionRepository) { + const [subscription] = await repository.listPushSubscriptions(USER_ID); + return subscription?.threadChannels; +} + +describe('PushSubscriptionRepository.createPushSubscription', () => { + beforeEach(() => { + executor = new CountingExecutor(); + setCassandraQueryExecutorForTesting(executor); + }); + afterEach(() => { + executor.reset(); + setCassandraQueryExecutorForTesting(null); + syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({})); + }); + + it('does no read and leaves the capability column unwritten on a never-enabled instance', async () => { + const repository = new PushSubscriptionRepository(); + await repository.createPushSubscription(row(true)); + setEverEnabled(false); + executor.selects = 0; + await repository.createPushSubscription(row()); + expect(executor.selects).toBe(0); + expect(await storedCapability(repository)).toBe(true); + }); + + it('clears a stored capability when a device re-registers without the header', async () => { + setEverEnabled(true); + const repository = new PushSubscriptionRepository(); + await repository.createPushSubscription(row(true)); + await repository.createPushSubscription(row()); + expect(await storedCapability(repository)).toBeFalsy(); + }); +}); diff --git a/fluxer_api/src/api/user/repositories/PushSubscriptionRepository.ts b/fluxer_api/src/api/user/repositories/PushSubscriptionRepository.ts index f8b513c83..507025432 100644 --- a/fluxer_api/src/api/user/repositories/PushSubscriptionRepository.ts +++ b/fluxer_api/src/api/user/repositories/PushSubscriptionRepository.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {UserID} from '@app/api/BrandedTypes'; -import {deleteOneOrMany, fetchMany, upsertOne} from '@app/api/database/CassandraQueryExecution'; +import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution'; import type {PushSubscriptionRow} from '@app/api/database/types/UserTypes'; +import {everEnabled} from '@app/api/experiment/ChannelThreadsGate'; import {PushSubscription} from '@app/api/models/PushSubscription'; import {PushSubscriptions} from '@app/api/Tables'; import {awaitAll} from '@app/api/utils/ConcurrencyUtils'; @@ -10,6 +11,11 @@ import {awaitAll} from '@app/api/utils/ConcurrencyUtils'; const FETCH_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({ where: PushSubscriptions.where.eq('user_id'), }); +const FETCH_PUSH_SUBSCRIPTION_THREAD_CHANNELS_CQL = PushSubscriptions.selectCql({ + columns: ['thread_channels'], + where: [PushSubscriptions.where.eq('user_id'), PushSubscriptions.where.eq('subscription_id')], + limit: 1, +}); const FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({ where: PushSubscriptions.where.in('user_id', 'user_ids'), }); @@ -21,8 +27,20 @@ export class PushSubscriptionRepository { } async createPushSubscription(data: PushSubscriptionRow): Promise { - await upsertOne(PushSubscriptions.upsertAll(data)); - return new PushSubscription(data); + const row = + data.thread_channels === undefined && everEnabled() && (await this.hasThreadChannels(data)) + ? {...data, thread_channels: null} + : data; + await upsertOne(PushSubscriptions.upsertAll(row)); + return new PushSubscription(row); + } + + private async hasThreadChannels(data: PushSubscriptionRow): Promise { + const existing = await fetchOne>( + FETCH_PUSH_SUBSCRIPTION_THREAD_CHANNELS_CQL, + {user_id: data.user_id, subscription_id: data.subscription_id}, + ); + return existing?.thread_channels === true; } async deletePushSubscription(userId: UserID, subscriptionId: string): Promise { diff --git a/fluxer_api/src/api/user/services/UserAccountRequestService.ts b/fluxer_api/src/api/user/services/UserAccountRequestService.ts index 32f32e740..f8a7d1de3 100644 --- a/fluxer_api/src/api/user/services/UserAccountRequestService.ts +++ b/fluxer_api/src/api/user/services/UserAccountRequestService.ts @@ -5,6 +5,7 @@ import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils'; import {requireSudoMode, type SudoVerificationResult} from '@app/api/auth/services/SudoVerificationService'; import {createChannelID, createGuildID, type UserID} from '@app/api/BrandedTypes'; import type {UserConnectionRow} from '@app/api/database/types/ConnectionTypes'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents'; import {isBlockedEmailDomain} from '@app/api/infrastructure/activity/SharedLists'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; @@ -263,12 +264,14 @@ export class UserAccountRequestService { async preloadMessages(params: { userId: UserID; + viewer: ThreadViewer; channels: ReadonlyArray; requestCache: RequestCache; }): Promise> { const channelIds = params.channels.map((channelId) => createChannelID(channelId)); return this.userChannelService.preloadDMMessages({ userId: params.userId, + viewer: params.viewer, channelIds, }); } diff --git a/fluxer_api/src/api/user/services/UserAccountSettingsService.ts b/fluxer_api/src/api/user/services/UserAccountSettingsService.ts index 9e0c1e61b..e926dbcfd 100644 --- a/fluxer_api/src/api/user/services/UserAccountSettingsService.ts +++ b/fluxer_api/src/api/user/services/UserAccountSettingsService.ts @@ -9,11 +9,13 @@ import { type UserID, } from '@app/api/BrandedTypes'; import type {ChannelOverride, UserGuildSettingsRow} from '@app/api/database/types/UserTypes'; +import {type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import type {GuildChannelOverride} from '@app/api/models/GuildChannelOverride'; import type {User} from '@app/api/models/User'; import type {UserGuildSettings} from '@app/api/models/UserGuildSettings'; import type {UserSettings} from '@app/api/models/UserSettings'; @@ -23,9 +25,11 @@ import type {IUserSettingsRepository} from '@app/api/user/repositories/IUserSett import {CustomStatusValidator} from '@app/api/user/services/CustomStatusValidator'; import type {UserAccountUpdatePropagator} from '@app/api/user/services/UserAccountUpdatePropagator'; import {getCachedUserPartialResponse} from '@app/api/user/UserCacheHelpers'; +import {controlUserGuildSettingsView} from '@app/api/user/UserGuildSettingsThreadView'; import {mapRelationshipToResponse} from '@app/api/user/UserMappers'; import {dedupeGuildFolders} from '@app/api/user/utils/GuildFolderUtils'; import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils'; +import {CHANNEL_OVERRIDE_FLAG_MASK} from '@fluxer/constants/src/ThreadConstants'; import { DEFAULT_GUILD_FOLDER_ICON, GroupDmAddPermissionFlags, @@ -291,6 +295,7 @@ export class UserAccountSettingsService { userId: UserID; guildId: GuildID | null; data: UserGuildSettingsUpdateRequest; + viewer?: ThreadViewer; }): Promise { const {userId, guildId, data} = params; const currentSettings = await this.deps.userSettingsRepository.findGuildSettings(userId, guildId); @@ -332,27 +337,37 @@ export class UserAccountSettingsService { if (data.hide_muted_channels !== undefined) updatedRowData.hide_muted_channels = data.hide_muted_channels; if (data.unread_badges !== undefined) updatedRowData.unread_badges = data.unread_badges ?? null; if (data.channel_overrides !== undefined) { - if (data.channel_overrides) { - const channelOverrides = new Map(); - for (const [channelIdStr, override] of Object.entries(data.channel_overrides)) { - const channelId = createChannelID(BigInt(channelIdStr)); - channelOverrides.set(channelId, { - collapsed: override.collapsed, - message_notifications: override.message_notifications, - muted: override.muted, - mute_config: override.mute_config - ? { - end_time: override.mute_config.end_time ?? null, - selected_time_window: override.mute_config.selected_time_window, - } - : null, - unread_badges: override.unread_badges ?? null, - }); - } - updatedRowData.channel_overrides = channelOverrides.size > 0 ? channelOverrides : null; - } else { - updatedRowData.channel_overrides = null; + const flagsWritable = guildId !== null && params.viewer !== undefined && viewerActive(params.viewer, guildId); + const previousOverrides = currentSettings?.channelOverrides ?? new Map(); + const channelOverrides = new Map(); + for (const [channelIdStr, override] of Object.entries(data.channel_overrides ?? {})) { + const channelId = createChannelID(BigInt(channelIdStr)); + const flags = flagsWritable + ? override.flags !== undefined + ? override.flags & CHANNEL_OVERRIDE_FLAG_MASK + : previousOverrides.get(channelId)?.flags + : previousOverrides.get(channelId)?.flags; + channelOverrides.set(channelId, { + collapsed: override.collapsed, + message_notifications: override.message_notifications, + muted: override.muted, + mute_config: override.mute_config + ? { + end_time: override.mute_config.end_time ?? null, + selected_time_window: override.mute_config.selected_time_window, + } + : null, + unread_badges: override.unread_badges ?? null, + ...(flags ? {flags} : {}), + }); } + if (!flagsWritable && currentSettings) { + const hidden = (await controlUserGuildSettingsView(currentSettings))?.hiddenChannelIds; + for (const [channelId, override] of previousOverrides) { + if (hidden?.has(channelId.toString())) channelOverrides.set(channelId, override.toChannelOverride()); + } + } + updatedRowData.channel_overrides = channelOverrides.size > 0 ? channelOverrides : null; } const updatedSettings = await this.deps.userSettingsRepository.upsertGuildSettings(updatedRowData); await this.deps.updatePropagator.dispatchUserGuildSettingsUpdate({userId, settings: updatedSettings}); diff --git a/fluxer_api/src/api/user/services/UserAccountUpdatePropagator.ts b/fluxer_api/src/api/user/services/UserAccountUpdatePropagator.ts index c5e098652..5734437d0 100644 --- a/fluxer_api/src/api/user/services/UserAccountUpdatePropagator.ts +++ b/fluxer_api/src/api/user/services/UserAccountUpdatePropagator.ts @@ -11,7 +11,8 @@ import type {UserSettings} from '@app/api/models/UserSettings'; import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository'; import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator'; import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation'; -import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@app/api/user/UserMappers'; +import {dispatchUserGuildSettingsUpdate} from '@app/api/user/UserGuildSettingsThreadView'; +import {mapUserSettingsToResponse} from '@app/api/user/UserMappers'; interface UserAccountUpdatePropagatorDeps { userCacheService: UserCacheService; @@ -48,17 +49,7 @@ export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator { userId: UserID; settings: UserGuildSettings; }): Promise { - const payload = mapUserGuildSettingsToResponse(settings); - await this.deps.gatewayService.dispatchPresence({ - userId, - event: 'USER_GUILD_SETTINGS_UPDATE', - data: payload, - }); - await this.deps.gatewayService.syncPushUserGuildSettings({ - userId, - guildId: settings.guildId, - settings: payload, - }); + await dispatchUserGuildSettingsUpdate(this.deps.gatewayService, userId, settings); } async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise { diff --git a/fluxer_api/src/api/user/services/UserChannelRequestService.ts b/fluxer_api/src/api/user/services/UserChannelRequestService.ts index 6969348bf..cad890fb4 100644 --- a/fluxer_api/src/api/user/services/UserChannelRequestService.ts +++ b/fluxer_api/src/api/user/services/UserChannelRequestService.ts @@ -2,6 +2,7 @@ import type {ChannelID, UserID} from '@app/api/BrandedTypes'; import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {Channel} from '@app/api/models/Channel'; @@ -24,6 +25,7 @@ interface UserChannelCreateParams { interface UserChannelPinParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; } @@ -83,10 +85,18 @@ export class UserChannelRequestService { } async pinChannel(params: UserChannelPinParams): Promise { - await this.userChannelService.pinDmChannel({userId: params.userId, channelId: params.channelId}); + await this.userChannelService.pinDmChannel({ + userId: params.userId, + viewer: params.viewer, + channelId: params.channelId, + }); } async unpinChannel(params: UserChannelPinParams): Promise { - await this.userChannelService.unpinDmChannel({userId: params.userId, channelId: params.channelId}); + await this.userChannelService.unpinDmChannel({ + userId: params.userId, + viewer: params.viewer, + channelId: params.channelId, + }); } } diff --git a/fluxer_api/src/api/user/services/UserChannelService.ts b/fluxer_api/src/api/user/services/UserChannelService.ts index 99ca5f612..777a52821 100644 --- a/fluxer_api/src/api/user/services/UserChannelService.ts +++ b/fluxer_api/src/api/user/services/UserChannelService.ts @@ -12,6 +12,7 @@ import { createMessageResponseDataService, messageResponseAccessForGuild, } from '@app/api/channel/services/message/MessageResponseDataService'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; @@ -232,8 +233,16 @@ export class UserChannelService { } } - async pinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise { - const channel = await this.channelService.channelData.operations.getChannel({userId, channelId}); + async pinDmChannel({ + userId, + channelId, + viewer, + }: { + userId: UserID; + viewer: ThreadViewer; + channelId: ChannelID; + }): Promise { + const channel = await this.channelService.channelData.operations.getChannel({userId, viewer, channelId}); if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) { throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.CHANNEL_MUST_BE_DM_OR_GROUP_DM); } @@ -248,8 +257,16 @@ export class UserChannelService { }); } - async unpinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise { - const channel = await this.channelService.channelData.operations.getChannel({userId, channelId}); + async unpinDmChannel({ + userId, + channelId, + viewer, + }: { + userId: UserID; + viewer: ThreadViewer; + channelId: ChannelID; + }): Promise { + const channel = await this.channelService.channelData.operations.getChannel({userId, viewer, channelId}); if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) { throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.CHANNEL_MUST_BE_DM_OR_GROUP_DM); } @@ -266,9 +283,10 @@ export class UserChannelService { async preloadDMMessages(params: { userId: UserID; + viewer: ThreadViewer; channelIds: Array; }): Promise> { - const {userId, channelIds} = params; + const {userId, viewer, channelIds} = params; if (channelIds.length > 100) { throw InputValidationError.fromCode('channels', ValidationErrorCodes.CANNOT_PRELOAD_MORE_THAN_100_CHANNELS); } @@ -276,7 +294,7 @@ export class UserChannelService { const results: Record = {}; const fetchPromises = channelIds.map(async (channelId) => { try { - const channel = await this.channelService.channelData.operations.getChannel({userId, channelId}); + const channel = await this.channelService.channelData.operations.getChannel({userId, viewer, channelId}); if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) { return; } diff --git a/fluxer_api/src/api/user/services/UserContentRequestService.ts b/fluxer_api/src/api/user/services/UserContentRequestService.ts index f0f5ebbbe..233076162 100644 --- a/fluxer_api/src/api/user/services/UserContentRequestService.ts +++ b/fluxer_api/src/api/user/services/UserContentRequestService.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {IStorageService} from '@app/api/infrastructure/IStorageService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; @@ -22,6 +23,7 @@ type HarvestLatestResponse = HarvestStatusResponse | null; interface UserMentionsParams { userId: UserID; + viewer: ThreadViewer; limit: number; roles: boolean; everyone: boolean; @@ -42,6 +44,7 @@ interface UserMentionsReadParams { interface SavedMessagesParams { userId: UserID; + viewer: ThreadViewer; limit: number; before?: MessageID; requestCache: RequestCache; @@ -49,6 +52,7 @@ interface SavedMessagesParams { interface SaveMessageParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; requestCache: RequestCache; @@ -88,13 +92,14 @@ export class UserContentRequestService { async listMentions(params: UserMentionsParams): Promise { const messages = await this.userContentService.getRecentMentions({ userId: params.userId, + viewer: params.viewer, limit: params.limit, everyone: params.everyone, roles: params.roles, guilds: params.guilds, before: params.before, }); - return this.userContentService.buildMessageResponsesForUser(params.userId, messages); + return this.userContentService.buildMessageResponsesForUser(params.userId, params.viewer, messages); } async deleteMention(params: UserMentionDeleteParams): Promise { @@ -108,11 +113,16 @@ export class UserContentRequestService { async listSavedMessages(params: SavedMessagesParams): Promise { const entries = await this.userContentService.getSavedMessages({ userId: params.userId, + viewer: params.viewer, limit: params.limit, before: params.before, }); const messages = entries.map((entry) => entry.message).filter((message): message is Message => message != null); - const responses = await this.userContentService.buildMessageResponsesForUser(params.userId, messages); + const responses = await this.userContentService.buildMessageResponsesForUser( + params.userId, + params.viewer, + messages, + ); const responseByMessageId = new Map(responses.map((response) => [response.id, response] as const)); return entries.map((entry) => this.mapSavedMessageEntry(entry, responseByMessageId.get(entry.messageId.toString()) ?? null), @@ -122,6 +132,7 @@ export class UserContentRequestService { async saveMessage(params: SaveMessageParams): Promise { await this.userContentService.saveMessage({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, messageId: params.messageId, userCacheService: this.userCacheService, diff --git a/fluxer_api/src/api/user/services/UserContentService.test.ts b/fluxer_api/src/api/user/services/UserContentService.test.ts index 2314bc022..939671d2a 100644 --- a/fluxer_api/src/api/user/services/UserContentService.test.ts +++ b/fluxer_api/src/api/user/services/UserContentService.test.ts @@ -1,18 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {ApiContext} from '@app/api/ApiContext'; -import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; -import {createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes'; +import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; +import {createChannelID, createGuildID, createMessageID, createUserID} from '@app/api/BrandedTypes'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; +import {SYSTEM_THREAD_VIEWER, syncChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate'; import type {KVBulkMessageDeletionQueueService} from '@app/api/infrastructure/KVBulkMessageDeletionQueueService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import type {Channel} from '@app/api/models/Channel'; import {Message} from '@app/api/models/Message'; import {UserContentService, UserContentServiceTestHooks} from '@app/api/user/services/UserContentService'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants'; import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError'; import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError'; @@ -22,7 +25,9 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError'; import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes'; -import {describe, expect, it, vi} from 'vitest'; +import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterEach, describe, expect, it, vi} from 'vitest'; const {isUnreachableEntityError} = UserContentServiceTestHooks; @@ -92,15 +97,18 @@ function createUserContentService({ readable, stored, failures = new Map(), + channels = [], }: { entries: Array<{channelId: ChannelID; messageId: MessageID}>; readable: Array<{channelId: ChannelID; messageId: MessageID}>; stored?: Array<{channelId: ChannelID; messageId: MessageID}>; failures?: Map; + channels?: Array<{id: ChannelID; type: number; guildId: GuildID | null}>; }) { const batchCalls: Array = []; const deletedSavedMessageIds: Array = []; const savedMessageListCalls: Array<{limit?: number; before?: MessageID}> = []; + const recentMentionListCalls: Array<{limit: number; before?: MessageID}> = []; const readableByChannel = new Map>(); for (const entry of readable) { const key = entry.channelId.toString(); @@ -109,10 +117,20 @@ function createUserContentService({ readableByChannel.set(key, messages); } const userRepository = { - listRecentMentions: async () => entries, + listRecentMentions: async ( + _userId: UserID, + _everyone: boolean, + _roles: boolean, + _guilds: boolean, + limit: number, + before?: MessageID, + ) => { + recentMentionListCalls.push({limit, before}); + return entries.filter((entry) => before === undefined || entry.messageId < before).slice(0, limit); + }, listSavedMessages: async (_userId: UserID, limit?: number, before?: MessageID) => { savedMessageListCalls.push({limit, before}); - return entries; + return entries.filter((entry) => before === undefined || entry.messageId < before).slice(0, limit); }, deleteSavedMessage: async (_userId: UserID, messageId: MessageID) => { deletedSavedMessageIds.push(messageId.toString()); @@ -137,6 +155,8 @@ function createUserContentService({ (stored ?? readable).map((entry) => `${entry.channelId.toString()}:${entry.messageId.toString()}`), ); const channelRepository = { + listChannels: async (channelIds: Array) => + channels.filter((channel) => channelIds.some((channelId) => channelId === channel.id)), messages: { getMessage: async (channelId: ChannelID, messageId: MessageID) => storedKeys.has(`${channelId.toString()}:${messageId.toString()}`) ? makeMessage(channelId, messageId) : null, @@ -150,7 +170,7 @@ function createUserContentService({ {} as unknown as KVBulkMessageDeletionQueueService, {} as unknown as LimitConfigService, ); - return {service, batchCalls, deletedSavedMessageIds, savedMessageListCalls}; + return {service, batchCalls, deletedSavedMessageIds, savedMessageListCalls, recentMentionListCalls}; } const CHANNEL_A = createChannelID(100n); @@ -168,6 +188,7 @@ describe('getRecentMentions', () => { const {service, batchCalls} = createUserContentService({entries, readable: entries}); const messages = await service.getRecentMentions({ + viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, limit: 50, everyone: true, @@ -199,6 +220,7 @@ describe('getRecentMentions', () => { }); const messages = await service.getRecentMentions({ + viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, limit: 50, everyone: true, @@ -217,6 +239,7 @@ describe('getRecentMentions', () => { const {service} = createUserContentService({entries, readable: [entries[1]]}); const messages = await service.getRecentMentions({ + viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, limit: 50, everyone: true, @@ -227,6 +250,106 @@ describe('getRecentMentions', () => { expect(messages.map((message) => message.id.toString())).toEqual(['12']); }); + describe('thread channels', () => { + const GUILD = createGuildID(900n); + const THREAD = createChannelID(400n); + const viewer = {kind: 'user', userId: VIEWER_ID, bot: false, capable: false} as const; + + afterEach(() => { + syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + }); + + const entries = [ + {channelId: THREAD, messageId: createMessageID(15n)}, + {channelId: THREAD, messageId: createMessageID(14n)}, + {channelId: CHANNEL_A, messageId: createMessageID(13n)}, + {channelId: CHANNEL_B, messageId: createMessageID(12n)}, + {channelId: CHANNEL_A, messageId: createMessageID(11n)}, + {channelId: CHANNEL_A, messageId: createMessageID(10n)}, + ]; + + function build() { + return createUserContentService({ + entries, + readable: entries.filter((entry) => entry.channelId === CHANNEL_A), + failures: new Map([ + [THREAD.toString(), new UnknownChannelError()], + [CHANNEL_B.toString(), new UnknownChannelError()], + ]), + channels: [ + {id: THREAD, type: ChannelTypes.PUBLIC_THREAD, guildId: GUILD}, + {id: CHANNEL_B, type: ChannelTypes.GUILD_TEXT, guildId: GUILD}, + ], + }); + } + + it('refills the page when the newest mentions sit in threads a non-viewer cannot see', async () => { + syncChannelThreadsConfig(JSON.stringify({enabled: false, ever_enabled: true}), (raw) => + ChannelThreadsConfigSchema.parse(JSON.parse(raw!)), + ); + const {service, recentMentionListCalls} = build(); + + const messages = await service.getRecentMentions({ + viewer, + userId: VIEWER_ID, + limit: 4, + everyone: true, + roles: true, + guilds: true, + }); + + expect(messages.map((message) => message.id.toString())).toEqual(['13', '11', '10']); + expect(recentMentionListCalls).toEqual([ + {limit: 4, before: undefined}, + {limit: 2, before: createMessageID(12n)}, + ]); + }); + + it('stops refilling after a bounded number of pages of hidden mentions', async () => { + syncChannelThreadsConfig(JSON.stringify({enabled: false, ever_enabled: true}), (raw) => + ChannelThreadsConfigSchema.parse(JSON.parse(raw!)), + ); + const hiddenEntries = Array.from({length: 50}, (_, index) => ({ + channelId: THREAD, + messageId: createMessageID(BigInt(100 - index)), + })); + const {service, recentMentionListCalls} = createUserContentService({ + entries: hiddenEntries, + readable: [], + failures: new Map([[THREAD.toString(), new UnknownChannelError()]]), + channels: [{id: THREAD, type: ChannelTypes.PUBLIC_THREAD, guildId: GUILD}], + }); + + const messages = await service.getRecentMentions({ + viewer, + userId: VIEWER_ID, + limit: 4, + everyone: true, + roles: true, + guilds: true, + }); + + expect(messages).toEqual([]); + expect(recentMentionListCalls).toHaveLength(4); + }); + + it('reads one page when the experiment was never enabled', async () => { + const {service, recentMentionListCalls} = build(); + + const messages = await service.getRecentMentions({ + viewer, + userId: VIEWER_ID, + limit: 4, + everyone: true, + roles: true, + guilds: true, + }); + + expect(messages.map((message) => message.id.toString())).toEqual(['13']); + expect(recentMentionListCalls).toHaveLength(1); + }); + }); + it('still lets an unexpected channel failure surface', async () => { const entries = [{channelId: CHANNEL_A, messageId: createMessageID(11n)}]; const {service} = createUserContentService({ @@ -236,7 +359,14 @@ describe('getRecentMentions', () => { }); await expect( - service.getRecentMentions({userId: VIEWER_ID, limit: 50, everyone: true, roles: true, guilds: true}), + service.getRecentMentions({ + viewer: SYSTEM_THREAD_VIEWER, + userId: VIEWER_ID, + limit: 50, + everyone: true, + roles: true, + guilds: true, + }), ).rejects.toThrow('database is on fire'); }); }); @@ -246,7 +376,12 @@ describe('getSavedMessages', () => { const entries = [{channelId: CHANNEL_A, messageId: createMessageID(11n)}]; const {service, savedMessageListCalls} = createUserContentService({entries, readable: entries}); - await service.getSavedMessages({userId: VIEWER_ID, limit: 50, before: createMessageID(20n)}); + await service.getSavedMessages({ + viewer: SYSTEM_THREAD_VIEWER, + userId: VIEWER_ID, + limit: 50, + before: createMessageID(20n), + }); expect(savedMessageListCalls).toEqual([{limit: 50, before: createMessageID(20n)}]); }); @@ -263,7 +398,7 @@ describe('getSavedMessages', () => { failures: new Map([[CHANNEL_B.toString(), new UnknownGuildError()]]), }); - const saved = await service.getSavedMessages({userId: VIEWER_ID, limit: 50}); + const saved = await service.getSavedMessages({viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, limit: 50}); expect( saved.map((entry) => ({id: entry.messageId.toString(), status: entry.status, hasMessage: entry.message != null})), @@ -290,7 +425,7 @@ describe('getSavedMessages', () => { stored: entries, }); - const saved = await service.getSavedMessages({userId: VIEWER_ID, limit: 50}); + const saved = await service.getSavedMessages({viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, limit: 50}); expect( saved.map((entry) => ({id: entry.messageId.toString(), status: entry.status, hasMessage: entry.message != null})), @@ -301,6 +436,90 @@ describe('getSavedMessages', () => { expect(deletedSavedMessageIds).toEqual([]); }); + describe('thread channels', () => { + const GUILD = createGuildID(900n); + const THREAD = createChannelID(400n); + const viewer = {kind: 'user', userId: VIEWER_ID, bot: false, capable: false} as const; + + function everEnabled() { + syncChannelThreadsConfig(JSON.stringify({enabled: false, ever_enabled: true}), (raw) => + ChannelThreadsConfigSchema.parse(JSON.parse(raw!)), + ); + } + + afterEach(() => { + syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + }); + + it('stops refilling saved messages after a bounded number of hidden pages', async () => { + everEnabled(); + const {service, savedMessageListCalls} = createUserContentService({ + entries: Array.from({length: 50}, (_, index) => ({ + channelId: THREAD, + messageId: createMessageID(BigInt(100 - index)), + })), + readable: [], + failures: new Map([[THREAD.toString(), new UnknownChannelError()]]), + channels: [{id: THREAD, type: ChannelTypes.PUBLIC_THREAD, guildId: GUILD}], + }); + + expect(await service.getSavedMessages({viewer, userId: VIEWER_ID, limit: 4})).toEqual([]); + expect(savedMessageListCalls).toHaveLength(4); + }); + + it('drops saved thread messages for a non-viewer and refills the page', async () => { + everEnabled(); + const entries = [ + {channelId: CHANNEL_A, messageId: createMessageID(15n)}, + {channelId: THREAD, messageId: createMessageID(14n)}, + {channelId: THREAD, messageId: createMessageID(13n)}, + {channelId: CHANNEL_A, messageId: createMessageID(12n)}, + {channelId: CHANNEL_B, messageId: createMessageID(11n)}, + {channelId: CHANNEL_A, messageId: createMessageID(10n)}, + ]; + const {service, savedMessageListCalls} = createUserContentService({ + entries, + readable: entries.filter((entry) => entry.channelId === CHANNEL_A), + failures: new Map([ + [THREAD.toString(), new UnknownChannelError()], + [CHANNEL_B.toString(), new UnknownChannelError()], + ]), + channels: [ + {id: THREAD, type: ChannelTypes.PUBLIC_THREAD, guildId: GUILD}, + {id: CHANNEL_B, type: ChannelTypes.GUILD_TEXT, guildId: GUILD}, + ], + }); + + const saved = await service.getSavedMessages({viewer, userId: VIEWER_ID, limit: 4}); + + expect(saved.map((entry) => [entry.messageId.toString(), entry.status])).toEqual([ + ['15', 'available'], + ['12', 'available'], + ['11', 'missing_permissions'], + ['10', 'available'], + ]); + expect(savedMessageListCalls).toEqual([ + {limit: 4, before: undefined}, + {limit: 2, before: createMessageID(12n)}, + ]); + }); + + it('keeps the unreachable stub when the experiment was never enabled', async () => { + const entries = [{channelId: THREAD, messageId: createMessageID(14n)}]; + const {service, savedMessageListCalls} = createUserContentService({ + entries, + readable: [], + failures: new Map([[THREAD.toString(), new UnknownChannelError()]]), + channels: [{id: THREAD, type: ChannelTypes.PUBLIC_THREAD, guildId: GUILD}], + }); + + const saved = await service.getSavedMessages({viewer, userId: VIEWER_ID, limit: 4}); + + expect(saved.map((entry) => entry.status)).toEqual(['missing_permissions']); + expect(savedMessageListCalls).toHaveLength(1); + }); + }); + it('deletes a saved message the repository no longer holds', async () => { const entries = [ {channelId: CHANNEL_A, messageId: createMessageID(11n)}, @@ -312,7 +531,7 @@ describe('getSavedMessages', () => { stored: [entries[0]], }); - const saved = await service.getSavedMessages({userId: VIEWER_ID, limit: 50}); + const saved = await service.getSavedMessages({viewer: SYSTEM_THREAD_VIEWER, userId: VIEWER_ID, limit: 50}); expect(saved.map((entry) => entry.messageId.toString())).toEqual(['11']); expect(deletedSavedMessageIds).toEqual(['12']); @@ -320,7 +539,10 @@ describe('getSavedMessages', () => { }); describe('gateway dispatches after the write', () => { - function createDispatchingService(dispatchPresence: () => Promise) { + function createDispatchingService( + dispatchPresence: (params: {event: string; data: unknown}) => Promise, + channel: {type: number; guildId: GuildID | null} = {type: ChannelTypes.GUILD_TEXT, guildId: null}, + ) { const createdSavedMessageIds: Array = []; const deletedSavedMessageIds: Array = []; const deletedRecentMentionIds: Array = []; @@ -351,7 +573,7 @@ describe('gateway dispatches after the write', () => { }, }; const channelService = { - channelData: {auth: {getChannelAuthenticated: async () => ({})}}, + channelData: {auth: {getChannelAuthenticated: async () => ({channel})}}, messages: {retrieval: {getMessage: async () => message}}, }; const service = new UserContentService( @@ -370,6 +592,7 @@ describe('gateway dispatches after the write', () => { function saveMessageArgs(messageId: MessageID) { return { userId: VIEWER_ID, + viewer: SYSTEM_THREAD_VIEWER, channelId: CHANNEL_A, messageId, userCacheService: {} as unknown as UserCacheService, @@ -381,7 +604,7 @@ describe('gateway dispatches after the write', () => { const {service, message, createdSavedMessageIds} = createDispatchingService(async () => { throw new BadGatewayError(); }); - vi.spyOn(service, 'buildMessageResponsesForUser').mockResolvedValue([]); + vi.spyOn(service, 'buildUnmaskedResponses').mockResolvedValue({responses: [], channelById: new Map()}); await expect(service.saveMessage(saveMessageArgs(message.id))).resolves.toBeUndefined(); @@ -390,11 +613,79 @@ describe('gateway dispatches after the write', () => { it('still surfaces a failure of the message response build', async () => { const {service, message} = createDispatchingService(async () => {}); - vi.spyOn(service, 'buildMessageResponsesForUser').mockRejectedValue(new Error('database is on fire')); + vi.spyOn(service, 'buildUnmaskedResponses').mockRejectedValue(new Error('database is on fire')); await expect(service.saveMessage(saveMessageArgs(message.id))).rejects.toThrow('database is on fire'); }); + describe('thread scoping', () => { + const GUILD = createGuildID(900n); + + function capture() { + const sent: Array = []; + return { + sent, + dispatch: async (params: {data: unknown}) => { + sent.push(params.data); + }, + }; + } + + function response(overrides: Partial): MessageResponse { + return {id: '31', channel_id: '100', type: MessageTypes.DEFAULT, flags: 0, ...overrides} as MessageResponse; + } + + it('sends a plain guild message unchanged', async () => { + const {sent, dispatch} = capture(); + const {service, message} = createDispatchingService(dispatch, {type: ChannelTypes.GUILD_TEXT, guildId: GUILD}); + const data = response({}); + vi.spyOn(service, 'buildUnmaskedResponses').mockResolvedValue({responses: [data], channelById: new Map()}); + + await service.saveMessage(saveMessageArgs(message.id)); + + expect(sent).toEqual([data]); + }); + + it('scopes a thread message to thread viewers only', async () => { + const {sent, dispatch} = capture(); + const {service, message} = createDispatchingService(dispatch, { + type: ChannelTypes.PUBLIC_THREAD, + guildId: GUILD, + }); + const data = response({channel_id: '400'}); + vi.spyOn(service, 'buildUnmaskedResponses').mockResolvedValue({responses: [data], channelById: new Map()}); + + await service.saveMessage(saveMessageArgs(message.id)); + + expect(sent).toEqual([{...data, __thread_scoped: '900'}]); + }); + + it('scopes a thread created message without an unscoped copy', async () => { + const {sent, dispatch} = capture(); + const {service, message} = createDispatchingService(dispatch, {type: ChannelTypes.GUILD_TEXT, guildId: GUILD}); + const data = response({type: MessageTypes.THREAD_CREATED}); + vi.spyOn(service, 'buildUnmaskedResponses').mockResolvedValue({responses: [data], channelById: new Map()}); + + await service.saveMessage(saveMessageArgs(message.id)); + + expect(sent).toEqual([{...data, __thread_scoped: '900'}]); + }); + + it('sends viewers the thread flag and everyone else a masked copy', async () => { + const {sent, dispatch} = capture(); + const {service, message} = createDispatchingService(dispatch, {type: ChannelTypes.GUILD_TEXT, guildId: GUILD}); + const data = response({flags: ServerMessageFlags.HAS_THREAD | 4}); + vi.spyOn(service, 'buildUnmaskedResponses').mockResolvedValue({responses: [data], channelById: new Map()}); + + await service.saveMessage(saveMessageArgs(message.id)); + + expect(sent).toEqual([ + {...data, __thread_scoped: '900'}, + {...data, flags: 4, __thread_unscoped: '900'}, + ]); + }); + }); + it('keeps the deletion when SAVED_MESSAGE_DELETE fails to publish', async () => { const {service, deletedSavedMessageIds} = createDispatchingService(async () => { throw new BadGatewayError(); @@ -418,6 +709,68 @@ describe('gateway dispatches after the write', () => { }); }); +describe('buildMessageResponsesForUser thread masking', () => { + const GUILD = createGuildID(900n); + const HAS_THREAD = ServerMessageFlags.HAS_THREAD; + + afterEach(() => { + syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {})); + }); + + function build(config: Record) { + syncChannelThreadsConfig(JSON.stringify(config), (raw) => ChannelThreadsConfigSchema.parse(JSON.parse(raw!))); + const {service} = createUserContentService({entries: [], readable: []}); + const threadCreated = {id: '40', channel_id: '100', type: MessageTypes.THREAD_CREATED, flags: 0}; + const responses = [ + {id: '41', channel_id: '100', type: MessageTypes.DEFAULT, flags: HAS_THREAD | 4}, + {id: '42', channel_id: '100', type: MessageTypes.REPLY, flags: 0, referenced_message: threadCreated}, + {id: '43', channel_id: '300', type: MessageTypes.DEFAULT, flags: HAS_THREAD}, + ] as Array; + const channelById = new Map([ + ['100', {id: CHANNEL_A, guildId: GUILD}], + ['300', {id: CHANNEL_C, guildId: null}], + ]) as unknown as Map; + vi.spyOn(service, 'buildUnmaskedResponses').mockResolvedValue({responses, channelById}); + return {service, responses}; + } + + const viewer = {kind: 'user', userId: VIEWER_ID, bot: false, capable: true} as const; + + it('masks the parent flag and a thread created reply for a non-viewer', async () => { + const {service} = build({enabled: true, ever_enabled: true, enabled_guild_ids: ['900']}); + + const out = await service.buildMessageResponsesForUser(VIEWER_ID, viewer, []); + + expect(out.map((response) => [response.id, response.flags, response.referenced_message])).toEqual([ + ['41', 4, undefined], + ['42', 0, null], + ['43', 0, undefined], + ]); + }); + + it('masks a killed tainted guild for everyone', async () => { + const {service} = build({enabled: false, ever_enabled: true}); + + const out = await service.buildMessageResponsesForUser(VIEWER_ID, viewer, []); + + expect(out.map((response) => response.flags)).toEqual([4, 0, 0]); + }); + + it('leaves guild responses untouched for an active viewer and still masks a DM', async () => { + const {service, responses} = build({ + enabled: true, + ever_enabled: true, + enabled_guild_ids: ['900'], + included_user_ids: [VIEWER_ID.toString()], + }); + + const out = await service.buildMessageResponsesForUser(VIEWER_ID, viewer, []); + + expect(out.slice(0, 2)).toEqual(responses.slice(0, 2)); + expect(out[2]?.flags).toBe(0); + }); +}); + describe('bookmark ceiling', () => { function createBookmarkLimitedService({ savedMessageCount, @@ -449,7 +802,9 @@ describe('bookmark ceiling', () => { }, }; const channelService = { - channelData: {auth: {getChannelAuthenticated: async () => ({})}}, + channelData: { + auth: {getChannelAuthenticated: async () => ({channel: {type: ChannelTypes.GUILD_TEXT, guildId: null}})}, + }, messages: {retrieval: {getMessage: async () => message}}, }; const snapshot: LimitConfigSnapshot = { @@ -472,6 +827,7 @@ describe('bookmark ceiling', () => { function saveMessageArgs(messageId: MessageID) { return { userId: VIEWER_ID, + viewer: SYSTEM_THREAD_VIEWER, channelId: CHANNEL_A, messageId, userCacheService: {} as unknown as UserCacheService, @@ -484,7 +840,7 @@ describe('bookmark ceiling', () => { savedMessageCount: 1002, maxBookmarks: 1002, }); - vi.spyOn(service, 'buildMessageResponsesForUser').mockResolvedValue([]); + vi.spyOn(service, 'buildUnmaskedResponses').mockResolvedValue({responses: [], channelById: new Map()}); const error = await service.saveMessage(saveMessageArgs(message.id)).catch((thrown: unknown) => thrown); @@ -500,7 +856,7 @@ describe('bookmark ceiling', () => { savedMessageCount: 1001, maxBookmarks: 1002, }); - vi.spyOn(service, 'buildMessageResponsesForUser').mockResolvedValue([]); + vi.spyOn(service, 'buildUnmaskedResponses').mockResolvedValue({responses: [], channelById: new Map()}); await expect(service.saveMessage(saveMessageArgs(message.id))).resolves.toBeUndefined(); diff --git a/fluxer_api/src/api/user/services/UserContentService.ts b/fluxer_api/src/api/user/services/UserContentService.ts index 216f41693..b10ffef73 100644 --- a/fluxer_api/src/api/user/services/UserContentService.ts +++ b/fluxer_api/src/api/user/services/UserContentService.ts @@ -8,7 +8,18 @@ import {Config} from '@app/api/Config'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; import {createMessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService'; +import { + carriesThreadArtifact, + maskThreadArtifacts, + maskThreadArtifactsByChannel, +} from '@app/api/channel/services/message/ThreadMessageResponses'; import type {PushSubscriptionRow} from '@app/api/database/types/UserTypes'; +import { + everEnabled, + THREAD_FEATURE_CHANNEL_TYPES, + type ThreadViewer, + viewerActive, +} from '@app/api/experiment/ChannelThreadsGate'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; import type {IStorageService} from '@app/api/infrastructure/IStorageService'; @@ -19,6 +30,7 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils'; import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import type {Channel} from '@app/api/models/Channel'; import type {Message} from '@app/api/models/Message'; import {PushSubscription} from '@app/api/models/PushSubscription'; import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository'; @@ -42,6 +54,7 @@ import {UserHarvestRepository} from '@app/api/user/UserHarvestRepository'; import {serializeSelfMessageFilter} from '@app/api/worker/utils/SelfMessageFilterPayload'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; import {MAX_BOOKMARKS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants'; +import {THREAD_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {UserFlags} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; @@ -83,6 +96,7 @@ interface RegisterMobileDeviceParams { userId: UserID; authSessionIdHash?: string | null; device: RegisterMobileDeviceRequest; + threadChannels?: boolean; } interface UnregisterMobileDeviceParams { @@ -95,6 +109,18 @@ interface UserContentRepository extends IUserAccountRepository, IUserContentRepo const WEB_PUSH_PLATFORM = 'web_push' as const; const DEFAULT_MOBILE_APP_ID = 'stable'; const DEFAULT_APNS_PROVIDER_ENVIRONMENT = 'production'; +const MAX_HIDDEN_REFILL_PAGES = 3; + +function savedMessageCreatePayloads(channel: Channel, data: MessageResponse | undefined): Array { + const guildId = channel.guildId?.toString(); + if (!data || !guildId) return [data]; + const inThread = THREAD_CHANNEL_TYPES.has(channel.type); + if (!inThread && !carriesThreadArtifact(data)) return [data]; + const scoped = {...data, __thread_scoped: guildId}; + if (inThread) return [scoped]; + const [masked] = maskThreadArtifacts([data]); + return masked ? [scoped, {...masked, __thread_unscoped: guildId}] : [scoped]; +} function createPushSubscriptionId(parts: Array): string { const stableInput = parts.map((part) => `${part.length}:${part}`).join('|'); @@ -196,23 +222,43 @@ export class UserContentService { async getRecentMentions(params: { userId: UserID; + viewer: ThreadViewer; limit: number; everyone: boolean; roles: boolean; guilds: boolean; before?: MessageID; }): Promise> { - const {userId, limit, everyone, roles, guilds, before} = params; - const mentions = await this.userRepository.listRecentMentions(userId, everyone, roles, guilds, limit, before); - const messagesByChannel = await this.readMessagesByChannel(userId, mentions); - const messages = mentions - .map((mention) => this.pickMessage(messagesByChannel, mention)) - .filter((message): message is Message => message != null); + const {userId, viewer, limit, everyone, roles, guilds, before} = params; + const messages: Array = []; + let cursor = before; + let pageLimit = limit; + for (let refills = 0; ; refills++) { + const mentions = await this.userRepository.listRecentMentions(userId, everyone, roles, guilds, pageLimit, cursor); + const messagesByChannel = await this.readMessagesByChannel(userId, viewer, mentions); + const hiddenChannelIds = await this.hiddenThreadChannelIds(viewer, messagesByChannel); + let hidden = 0; + for (const mention of mentions) { + if (hiddenChannelIds.has(mention.channelId.toString())) { + hidden++; + continue; + } + const message = this.pickMessage(messagesByChannel, mention); + if (message) messages.push(message); + } + if (hidden === 0 || mentions.length < pageLimit || refills === MAX_HIDDEN_REFILL_PAGES) break; + cursor = mentions.reduce( + (oldest, mention) => (mention.messageId < oldest ? mention.messageId : oldest), + mentions[0]!.messageId, + ); + pageLimit = hidden; + } return messages.sort((a, b) => (b.id > a.id ? 1 : -1)); } private async readMessagesByChannel( userId: UserID, + viewer: ThreadViewer, entries: ReadonlyArray<{channelId: ChannelID; messageId: MessageID}>, ): Promise | null>> { const grouped = new Map}>(); @@ -231,6 +277,7 @@ export class UserContentService { try { const messages = await this.channelService.messages.retrieval.getMessagesByIds({ userId, + viewer, channelId: group.channelId, messageIds: group.messageIds, }); @@ -275,20 +322,48 @@ export class UserContentService { async getSavedMessages({ userId, + viewer, limit, before, }: { userId: UserID; + viewer: ThreadViewer; limit: number; before?: MessageID; }): Promise> { - const savedMessages = await this.userRepository.listSavedMessages(userId, limit, before); - const messagesByChannel = await this.readMessagesByChannel(userId, savedMessages); const results: Array = []; + let cursor = before; + let pageLimit = limit; + for (let refills = 0; ; refills++) { + const savedMessages = await this.userRepository.listSavedMessages(userId, pageLimit, cursor); + const hidden = await this.collectSavedMessages(userId, viewer, savedMessages, results); + if (hidden === 0 || savedMessages.length < pageLimit || refills === MAX_HIDDEN_REFILL_PAGES) break; + cursor = savedMessages.reduce( + (oldest, savedMessage) => (savedMessage.messageId < oldest ? savedMessage.messageId : oldest), + savedMessages[0]!.messageId, + ); + pageLimit = hidden; + } + return results.sort((a, b) => (b.messageId > a.messageId ? 1 : a.messageId > b.messageId ? -1 : 0)); + } + + private async collectSavedMessages( + userId: UserID, + viewer: ThreadViewer, + savedMessages: ReadonlyArray<{channelId: ChannelID; messageId: MessageID}>, + results: Array, + ): Promise { + const messagesByChannel = await this.readMessagesByChannel(userId, viewer, savedMessages); + const hiddenChannelIds = await this.hiddenThreadChannelIds(viewer, messagesByChannel); + let hidden = 0; const staleMessageIds: Array = []; for (const savedMessage of savedMessages) { const channelMessages = messagesByChannel.get(savedMessage.channelId.toString()); if (channelMessages === null) { + if (hiddenChannelIds.has(savedMessage.channelId.toString())) { + hidden++; + continue; + } results.push({ channelId: savedMessage.channelId, messageId: savedMessage.messageId, @@ -320,17 +395,40 @@ export class UserContentService { }); } await Promise.all(staleMessageIds.map((messageId) => this.userRepository.deleteSavedMessage(userId, messageId))); - return results.sort((a, b) => (b.messageId > a.messageId ? 1 : a.messageId > b.messageId ? -1 : 0)); + return hidden; + } + + private async hiddenThreadChannelIds( + viewer: ThreadViewer, + messagesByChannel: ReadonlyMap | null>, + ): Promise> { + if (!everEnabled()) return new Set(); + const unreachable = Array.from(messagesByChannel) + .filter(([, messages]) => messages === null) + .map(([channelId]) => createChannelID(BigInt(channelId))); + if (unreachable.length === 0) return new Set(); + const channels = await this.channelRepository.listChannels(unreachable); + return new Set( + channels + .filter( + (channel) => + THREAD_FEATURE_CHANNEL_TYPES.has(channel.type) && + (channel.guildId === null || !viewerActive(viewer, channel.guildId)), + ) + .map((channel) => channel.id.toString()), + ); } async saveMessage({ userId, + viewer, channelId, messageId, userCacheService, requestCache, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; messageId: MessageID; userCacheService: UserCacheService; @@ -351,13 +449,13 @@ export class UserContentService { if (savedMessageCount >= maxBookmarks) { throw new MaxBookmarksError({maxBookmarks}); } - await this.channelService.channelData.auth.getChannelAuthenticated({userId, channelId}); - const message = await this.channelService.messages.retrieval.getMessage({userId, channelId, messageId}); + const {channel} = await this.channelService.channelData.auth.getChannelAuthenticated({userId, channelId, viewer}); + const message = await this.channelService.messages.retrieval.getMessage({userId, viewer, channelId, messageId}); if (!message) { throw new UnknownMessageError(); } await this.userRepository.createSavedMessage(userId, channelId, messageId); - await this.dispatchSavedMessageCreate({userId, message, userCacheService, requestCache}); + await this.dispatchSavedMessageCreate({userId, message, channel, userCacheService, requestCache}); } async unsaveMessage({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise { @@ -376,8 +474,9 @@ export class UserContentService { userAgent?: string; originKind?: WebPushOriginKind | null; installedApp?: boolean; + threadChannels?: boolean; }): Promise { - const {userId, authSessionIdHash, endpoint, keys, userAgent, originKind, installedApp} = params; + const {userId, authSessionIdHash, endpoint, keys, userAgent, originKind, installedApp, threadChannels} = params; assertPublicPushEndpoint(endpoint, 'endpoint'); const subscriptionId = createWebPushSubscriptionId(endpoint); const data: PushSubscriptionRow = { @@ -391,6 +490,7 @@ export class UserContentService { platform: WEB_PUSH_PLATFORM, app_id: null, provider_environment: null, + ...(threadChannels === true ? {thread_channels: true} : {}), }; const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true); return subscription; @@ -482,8 +582,19 @@ export class UserContentService { userAgent?: string; originKind?: WebPushOriginKind | null; installedApp?: boolean; + threadChannels?: boolean; }): Promise { - const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent, originKind, installedApp} = params; + const { + userId, + authSessionIdHash, + oldEndpoint, + endpoint, + keys, + userAgent, + originKind, + installedApp, + threadChannels, + } = params; assertPublicPushEndpoint(endpoint, 'endpoint'); const oldSubscriptionId = createWebPushSubscriptionId(oldEndpoint); const newSubscriptionId = createWebPushSubscriptionId(endpoint); @@ -501,13 +612,14 @@ export class UserContentService { platform: WEB_PUSH_PLATFORM, app_id: null, provider_environment: null, + ...(threadChannels === true ? {thread_channels: true} : {}), }; const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true); return subscription; } async registerMobileDevice(params: RegisterMobileDeviceParams): Promise { - const {userId, authSessionIdHash, device} = params; + const {userId, authSessionIdHash, device, threadChannels} = params; const webPushKeys = resolveMobileWebPushKeys(device); if (webPushKeys) { assertPublicPushEndpoint(device.token, 'token'); @@ -526,6 +638,7 @@ export class UserContentService { platform: device.platform, app_id: appId, provider_environment: providerEnvironment, + ...(threadChannels === true ? {thread_channels: true} : {}), }; const subscription = await this.userRepository.createPushSubscription(data); return subscription; @@ -816,41 +929,58 @@ export class UserContentService { async dispatchSavedMessageCreate({ userId, message, + channel, }: { userId: UserID; message: Message; + channel: Channel; userCacheService: UserCacheService; requestCache: RequestCache; }): Promise { - const data = (await this.buildMessageResponsesForUser(userId, [message]))[0]; + const data = (await this.buildUnmaskedResponses(userId, [message])).responses[0]; if (!data) return; - await this.gatewayService - .dispatchPresence({ - userId, - event: 'SAVED_MESSAGE_CREATE', - data, - }) - .catch((error) => { - Logger.error( - {userId: userId.toString(), messageId: message.id.toString(), error}, - 'Failed to dispatch SAVED_MESSAGE_CREATE', - ); - return null; - }); + for (const payload of savedMessageCreatePayloads(channel, data)) { + await this.gatewayService + .dispatchPresence({ + userId, + event: 'SAVED_MESSAGE_CREATE', + data: payload, + }) + .catch((error) => { + Logger.error( + {userId: userId.toString(), messageId: message.id.toString(), error}, + 'Failed to dispatch SAVED_MESSAGE_CREATE', + ); + return null; + }); + } } - async buildMessageResponsesForUser(userId: UserID, messages: Array): Promise> { - if (messages.length === 0) return []; + async buildMessageResponsesForUser( + userId: UserID, + viewer: ThreadViewer, + messages: Array, + ): Promise> { + const {responses, channelById} = await this.buildUnmaskedResponses(userId, messages); + return maskThreadArtifactsByChannel(viewer, channelById, responses); + } + + async buildUnmaskedResponses( + userId: UserID, + messages: Array, + ): Promise<{responses: Array; channelById: Map}> { + if (messages.length === 0) return {responses: [], channelById: new Map()}; const channelIds = Array.from(new Set(messages.map((message) => message.channelId.toString()))); const channels = await this.channelRepository.listChannels( channelIds.map((channelId) => createChannelID(BigInt(channelId))), ); const channelById = new Map(channels.map((channel) => [channel.id.toString(), channel] as const)); - return createMessageResponseDataService().buildMessagesForChannels({ + const responses = await createMessageResponseDataService().buildMessagesForChannels({ userId, messages, channelById, }); + return {responses, channelById}; } async dispatchSavedMessageDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise { diff --git a/fluxer_api/src/api/user/tests/ThreadUserSettings.test.ts b/fluxer_api/src/api/user/tests/ThreadUserSettings.test.ts new file mode 100644 index 000000000..253e20883 --- /dev/null +++ b/fluxer_api/src/api/user/tests/ThreadUserSettings.test.ts @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID} from '@app/api/BrandedTypes'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + resetChannelThreadsConfig, + setChannelThreadsConfig, + THREADS_FEATURE, + THREADS_FEATURE_HEADER, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {insertGuildThreadMarker} from '@app/api/experiment/ChannelThreadsGate'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ChannelOverrideFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {UserGuildSettingsResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +const repository = new ChannelRepository(); + +function override(extra: Record = {}) { + return {collapsed: false, message_notifications: 1, muted: false, mute_config: null, ...extra}; +} + +describe('thread user settings', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup() { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'settings'); + const channel = await createChannel(harness, account.token, guild.id, 'general'); + await setChannelThreadsConfig({ + enabled: true, + enabled_guild_ids: [guild.id], + included_user_ids: [account.userId], + }); + return {account, guildId: guild.id, channelId: channel.id}; + } + + async function createForum(guildId: string, textChannelId: string): Promise { + const text = await repository.channelData.findUnique(createChannelID(BigInt(textChannelId))); + const forumId = (BigInt(textChannelId) + 7n).toString(); + await repository.channelData.upsert({ + ...text!.toRow(), + channel_id: createChannelID(BigInt(forumId)), + type: ChannelTypes.GUILD_FORUM, + name: 'forum', + }); + await insertGuildThreadMarker(createGuildID(BigInt(guildId)), null); + return forumId; + } + + function patchSettings(account: TestAccount, guildId: string, body: Record, capable: boolean) { + return threadsRequest(harness, account.token, {capable}) + .patch(`/users/@me/guilds/${guildId}/settings`) + .body(body) + .expect(200) + .execute(); + } + + describe('override flags', () => { + it('stores NEW_FORUM_THREADS flags for viewers and masks unknown bits', async () => { + const s = await setup(); + const response = await patchSettings( + s.account, + s.guildId, + {channel_overrides: {[s.channelId]: override({flags: ChannelOverrideFlags.NEW_FORUM_THREADS_ON | 1})}}, + true, + ); + expect(response.channel_overrides?.[s.channelId]?.flags).toBe(ChannelOverrideFlags.NEW_FORUM_THREADS_ON); + }); + + it('strips flags from non-viewers, carries stored flags forward and hides them on read', async () => { + const s = await setup(); + await patchSettings( + s.account, + s.guildId, + {channel_overrides: {[s.channelId]: override({flags: ChannelOverrideFlags.NEW_FORUM_THREADS_OFF})}}, + true, + ); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence'); + const control = await patchSettings( + s.account, + s.guildId, + {channel_overrides: {[s.channelId]: override({muted: true, flags: ChannelOverrideFlags.NEW_FORUM_THREADS_ON})}}, + false, + ); + expect(control.channel_overrides?.[s.channelId]).toEqual(expect.not.objectContaining({flags: expect.anything()})); + expect(control.channel_overrides?.[s.channelId]?.muted).toBe(true); + const updates = dispatch.mock.calls + .map(([params]) => params) + .filter((params) => params.event === 'USER_GUILD_SETTINGS_UPDATE'); + const viewer = await createBuilder(harness, s.account.token) + .header(THREADS_FEATURE_HEADER, THREADS_FEATURE) + .patch(`/users/@me/guilds/${s.guildId}/settings`) + .body({muted: false}) + .expect(200) + .execute(); + expect(viewer.channel_overrides?.[s.channelId]?.flags).toBe(ChannelOverrideFlags.NEW_FORUM_THREADS_OFF); + expect(updates).toHaveLength(2); + expect(updates[0]?.data).not.toHaveProperty('__thread_scoped'); + expect(updates[0]?.data).toMatchObject({__thread_unscoped: s.guildId}); + expect(JSON.stringify(updates[0]?.data)).not.toContain('"flags"'); + expect(updates[1]?.data).toMatchObject({__thread_scoped: s.guildId}); + }); + + it('ignores malformed flags from non-viewers and rejects them from viewers', async () => { + const s = await setup(); + const body = {channel_overrides: {[s.channelId]: override({flags: 'bogus'})}}; + await patchSettings(s.account, s.guildId, body, false); + await createBuilder(harness, s.account.token) + .patch('/users/@me/guilds/@me/settings') + .body(body) + .expect(200) + .execute(); + await threadsRequest(harness, s.account.token) + .patch(`/users/@me/guilds/${s.guildId}/settings`) + .body(body) + .expect(400) + .execute(); + }); + + it('sends a single settings update when no override carries thread data', async () => { + const s = await setup(); + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence'); + await patchSettings(s.account, s.guildId, {channel_overrides: {[s.channelId]: override()}}, true); + const updates = dispatch.mock.calls.filter(([params]) => params.event === 'USER_GUILD_SETTINGS_UPDATE'); + expect(updates).toHaveLength(1); + expect(updates[0]?.[0].data).not.toHaveProperty('__thread_scoped'); + expect(updates[0]?.[0].data).not.toHaveProperty('__thread_unscoped'); + }); + }); + + describe('forum override omission', () => { + it('hides forum-keyed overrides from non-viewers in a tainted guild and preserves them on write', async () => { + const s = await setup(); + const forumId = await createForum(s.guildId, s.channelId); + await patchSettings( + s.account, + s.guildId, + {channel_overrides: {[s.channelId]: override(), [forumId]: override({muted: true})}}, + true, + ); + const control = await patchSettings( + s.account, + s.guildId, + {channel_overrides: {[s.channelId]: override({collapsed: true})}}, + false, + ); + expect(Object.keys(control.channel_overrides ?? {})).toEqual([s.channelId]); + const viewer = await patchSettings(s.account, s.guildId, {muted: true}, true); + expect(viewer.channel_overrides?.[forumId]?.muted).toBe(true); + expect(viewer.channel_overrides?.[s.channelId]?.collapsed).toBe(true); + await setChannelThreadsConfig({enabled: false}); + const killed = await patchSettings(s.account, s.guildId, {muted: false}, true); + expect(Object.keys(killed.channel_overrides ?? {})).toEqual([s.channelId]); + }); + }); + + describe('device capability', () => { + interface PushSubscriptionsRpc { + data: Record>; + } + + async function subscriptions(account: TestAccount) { + const response = await createBuilder(harness, '') + .post('/test/rpc-session-init') + .body({type: 'get_push_subscriptions', user_ids: [account.userId]}) + .expect(200) + .execute(); + return response.data[account.userId] ?? []; + } + + it('records the thread capability of a mobile device only when the features header carries it', async () => { + const account = await createTestAccount(harness); + const capable = await threadsRequest<{device_id: string}>(harness, account.token) + .post('/users/@me/mobile-devices') + .body({platform: 'android_fcm', token: 'capable-device-token'}) + .execute(); + const legacy = await threadsRequest<{device_id: string}>(harness, account.token, {capable: false}) + .post('/users/@me/mobile-devices') + .body({platform: 'android_fcm', token: 'legacy-device-token'}) + .execute(); + const byId = new Map((await subscriptions(account)).map((sub) => [sub.subscription_id, sub])); + expect(byId.get(capable.device_id)?.thread_channels).toBe(true); + expect(byId.get(legacy.device_id)).toBeDefined(); + expect(byId.get(legacy.device_id)).not.toHaveProperty('thread_channels'); + }); + + it('clears the thread capability on a header-less re-registration only once the experiment was ever enabled', async () => { + const account = await createTestAccount(harness); + const register = (capable: boolean) => + threadsRequest<{device_id: string}>(harness, account.token, {capable}) + .post('/users/@me/mobile-devices') + .body({platform: 'android_fcm', token: 'downgraded-device-token'}) + .execute(); + const capability = async (deviceId: string) => + (await subscriptions(account)).find((sub) => sub.subscription_id === deviceId)?.thread_channels; + + const {device_id} = await register(true); + await register(false); + expect(await capability(device_id)).toBe(true); + + await setChannelThreadsConfig({enabled: true, included_user_ids: [account.userId]}); + await register(true); + expect(await capability(device_id)).toBe(true); + await register(false); + expect(await capability(device_id)).toBeUndefined(); + }); + }); +}); diff --git a/fluxer_api/src/api/utils/PermissionUtils.ts b/fluxer_api/src/api/utils/PermissionUtils.ts index b90b46b31..e5ba95598 100644 --- a/fluxer_api/src/api/utils/PermissionUtils.ts +++ b/fluxer_api/src/api/utils/PermissionUtils.ts @@ -3,6 +3,7 @@ import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError'; interface PermissionsDiff { @@ -10,10 +11,17 @@ interface PermissionsDiff { removed: Array; } -export function computePermissionsDiff(oldPermissions: bigint, newPermissions: bigint): PermissionsDiff { +export function computePermissionsDiff( + oldPermissions: bigint, + newPermissions: bigint, + opts?: {threads?: boolean}, +): PermissionsDiff { const added: Array = []; const removed: Array = []; - for (const [name, value] of Object.entries(Permissions)) { + const entries = opts?.threads + ? [...Object.entries(Permissions), ...Object.entries(ThreadPermissionFlags)] + : Object.entries(Permissions); + for (const [name, value] of entries) { const hadPermission = (oldPermissions & value) !== 0n; const hasPermission = (newPermissions & value) !== 0n; if (!hadPermission && hasPermission) { diff --git a/fluxer_api/src/api/utils/featureUtils.ts b/fluxer_api/src/api/utils/featureUtils.ts index 2a64400f2..c724407ba 100644 --- a/fluxer_api/src/api/utils/featureUtils.ts +++ b/fluxer_api/src/api/utils/featureUtils.ts @@ -1,22 +1,48 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {Headers} from '@fluxer/constants/src/Headers'; +import {THREAD_AWARE_ALL_PERMISSIONS, THREAD_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils'; export const CLIENT_FEATURES_HEADER = Headers.X_FLUXER_FEATURES; +export const CHANNEL_THREADS_CLIENT_FEATURE = 'channel_threads'; + +export type ThreadPermissionMode = 'control' | 'active' | 'retired'; + +export interface ProtectedBitActor { + clientFeatures: ReadonlySet; + threadBits?: {mode: ThreadPermissionMode; writer: boolean}; +} + +function threadBitsWritable(actor: ProtectedBitActor): boolean { + const threadBits = actor.threadBits; + if (!threadBits || threadBits.mode === 'control') return true; + return threadBits.mode === 'active' && threadBits.writer; +} + +export function protectedThreadBits(actor: ProtectedBitActor): bigint { + return threadBitsWritable(actor) ? 0n : THREAD_PERMISSIONS; +} + +export function permissionWriteMask(actor: ProtectedBitActor): bigint { + return !actor.threadBits || actor.threadBits.mode === 'control' ? ALL_PERMISSIONS : THREAD_AWARE_ALL_PERMISSIONS; +} + const PERMISSION_BIT_CLIENT_FEATURES: ReadonlyArray<{ bit: bigint; - feature: string; -}> = [{bit: Permissions.VIEW_CHANNEL_MEMBERS, feature: 'view_channel_members_permission'}]; + writable: (actor: ProtectedBitActor) => boolean; +}> = [ + { + bit: Permissions.VIEW_CHANNEL_MEMBERS, + writable: (actor) => actor.clientFeatures.has('view_channel_members_permission'), + }, + {bit: THREAD_PERMISSIONS, writable: threadBitsWritable}, +]; -export function applyProtectedRolePermissions( - requested: bigint, - existing: bigint, - clientFeatures: ReadonlySet, -): bigint { +export function applyProtectedRolePermissions(requested: bigint, existing: bigint, actor: ProtectedBitActor): bigint { let result = requested; - for (const {bit, feature} of PERMISSION_BIT_CLIENT_FEATURES) { - if (clientFeatures.has(feature)) continue; + for (const {bit, writable} of PERMISSION_BIT_CLIENT_FEATURES) { + if (writable(actor)) continue; result = (result & ~bit) | (existing & bit); } return result; @@ -31,15 +57,15 @@ export function applyProtectedOverwriteBits( allow: bigint; deny: bigint; }, - clientFeatures: ReadonlySet, + actor: ProtectedBitActor, ): { allow: bigint; deny: bigint; } { let allow = requested.allow; let deny = requested.deny; - for (const {bit, feature} of PERMISSION_BIT_CLIENT_FEATURES) { - if (clientFeatures.has(feature)) continue; + for (const {bit, writable} of PERMISSION_BIT_CLIENT_FEATURES) { + if (writable(actor)) continue; allow = (allow & ~bit) | (existing.allow & bit); deny = (deny & ~bit) | (existing.deny & bit); } @@ -50,9 +76,11 @@ const MAX_CLIENT_FEATURES = 64; const MAX_CLIENT_FEATURE_LENGTH = 64; const VALID_FEATURE_NAME = /^[a-z0-9_]+$/; +const NO_CLIENT_FEATURES: ReadonlySet = new Set(); + export function parseClientFeaturesHeader(headerValue: string | null | undefined): ReadonlySet { if (!headerValue) { - return new Set(); + return NO_CLIENT_FEATURES; } const features = new Set(); for (const raw of headerValue.split(',')) { diff --git a/fluxer_api/src/api/utils/tests/featureUtils.test.ts b/fluxer_api/src/api/utils/tests/featureUtils.test.ts index 835f2b0ee..60c3dc35c 100644 --- a/fluxer_api/src/api/utils/tests/featureUtils.test.ts +++ b/fluxer_api/src/api/utils/tests/featureUtils.test.ts @@ -42,33 +42,33 @@ describe('applyProtectedRolePermissions', () => { it('preserves protected bits when client lacks the feature', () => { const existing = VCM | OTHER_BIT; const requested = OTHER_BIT; - const result = applyProtectedRolePermissions(requested, existing, new Set()); + const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set()}); expect((result & VCM) === VCM).toBe(true); expect((result & OTHER_BIT) === OTHER_BIT).toBe(true); }); it('respects client-driven clears when feature is opted in', () => { const existing = VCM | OTHER_BIT; const requested = OTHER_BIT; - const result = applyProtectedRolePermissions(requested, existing, new Set([FEATURE])); + const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set([FEATURE])}); expect((result & VCM) === 0n).toBe(true); expect((result & OTHER_BIT) === OTHER_BIT).toBe(true); }); it('does not invent bits the existing value did not have', () => { const existing = OTHER_BIT; const requested = OTHER_BIT; - const result = applyProtectedRolePermissions(requested, existing, new Set()); + const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set()}); expect((result & VCM) === 0n).toBe(true); }); it('also blocks an old client from setting the bit (the bit is fully invisible without the feature)', () => { const existing = 0n; const requested = VCM; - const result = applyProtectedRolePermissions(requested, existing, new Set()); + const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set()}); expect((result & VCM) === 0n).toBe(true); }); it('lets a feature-aware client set the bit', () => { const existing = 0n; const requested = VCM; - const result = applyProtectedRolePermissions(requested, existing, new Set([FEATURE])); + const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set([FEATURE])}); expect((result & VCM) === VCM).toBe(true); }); }); @@ -77,51 +77,51 @@ describe('applyProtectedOverwriteBits', () => { it('preserves bit on allow when feature absent', () => { const existing = {allow: VCM, deny: 0n}; const requested = {allow: 0n, deny: 0n}; - const result = applyProtectedOverwriteBits(requested, existing, new Set()); + const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()}); expect((result.allow & VCM) === VCM).toBe(true); expect((result.deny & VCM) === 0n).toBe(true); }); it('preserves bit on deny when feature absent (the security-relevant case)', () => { const existing = {allow: 0n, deny: VCM}; const requested = {allow: 0n, deny: 0n}; - const result = applyProtectedOverwriteBits(requested, existing, new Set()); + const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()}); expect((result.deny & VCM) === VCM).toBe(true); }); it('preserves the bit on whichever side it was set', () => { const existing = {allow: VCM | OTHER_BIT, deny: 0n}; const requested = {allow: OTHER_BIT, deny: 0n}; - const result = applyProtectedOverwriteBits(requested, existing, new Set()); + const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()}); expect((result.allow & VCM) === VCM).toBe(true); expect((result.allow & OTHER_BIT) === OTHER_BIT).toBe(true); }); it('respects client-driven clears when feature is opted in (allow)', () => { const existing = {allow: VCM, deny: 0n}; const requested = {allow: 0n, deny: 0n}; - const result = applyProtectedOverwriteBits(requested, existing, new Set([FEATURE])); + const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set([FEATURE])}); expect((result.allow & VCM) === 0n).toBe(true); }); it('respects client-driven clears when feature is opted in (deny)', () => { const existing = {allow: 0n, deny: VCM}; const requested = {allow: 0n, deny: 0n}; - const result = applyProtectedOverwriteBits(requested, existing, new Set([FEATURE])); + const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set([FEATURE])}); expect((result.deny & VCM) === 0n).toBe(true); }); it('handles missing existing overwrite (treat as 0n) and blocks set without feature', () => { const existing = {allow: 0n, deny: 0n}; const requested = {allow: VCM, deny: 0n}; - const result = applyProtectedOverwriteBits(requested, existing, new Set()); + const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()}); expect((result.allow & VCM) === 0n).toBe(true); }); it('lets a feature-aware client set the bit on a fresh overwrite', () => { const existing = {allow: 0n, deny: 0n}; const requested = {allow: VCM, deny: 0n}; - const result = applyProtectedOverwriteBits(requested, existing, new Set([FEATURE])); + const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set([FEATURE])}); expect((result.allow & VCM) === VCM).toBe(true); }); it('does not flip bits between allow and deny', () => { const existing = {allow: 0n, deny: VCM}; const requested = {allow: 0n, deny: 0n}; - const result = applyProtectedOverwriteBits(requested, existing, new Set()); + const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()}); expect((result.allow & VCM) === 0n).toBe(true); expect((result.deny & VCM) === VCM).toBe(true); }); diff --git a/fluxer_api/src/api/webhook/ChannelFollowService.ts b/fluxer_api/src/api/webhook/ChannelFollowService.ts index 23246a040..a2619142b 100644 --- a/fluxer_api/src/api/webhook/ChannelFollowService.ts +++ b/fluxer_api/src/api/webhook/ChannelFollowService.ts @@ -5,6 +5,7 @@ import {createMessageID, createWebhookID, createWebhookToken} from '@app/api/Bra import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import {withChannelFollowLock} from '@app/api/channel/services/ChannelFollowers'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; +import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildService} from '@app/api/guild/services/GuildService'; import type {AvatarService} from '@app/api/infrastructure/AvatarService'; import {contentModerationService} from '@app/api/infrastructure/ContentModerationService'; @@ -38,6 +39,7 @@ import type {ICacheService} from '@pkgs/cache/src/ICacheService'; interface FollowChannelParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; webhookChannelId: ChannelID; requestCache: RequestCache; @@ -73,8 +75,8 @@ export class ChannelFollowService { ) {} async followChannel(params: FollowChannelParams): Promise { - const {userId, channelId, webhookChannelId, requestCache, auditLogReason} = params; - const sourceAuth = await this.channelService.channelData.auth.getChannelAuthenticated({userId, channelId}); + const {userId, viewer, channelId, webhookChannelId, requestCache, auditLogReason} = params; + const sourceAuth = await this.channelService.channelData.auth.getChannelAuthenticated({userId, channelId, viewer}); const source = sourceAuth.channel; const sourceGuild = sourceAuth.guild; if (source.type !== ChannelTypes.GUILD_ANNOUNCEMENT || !source.guildId || !sourceGuild) { @@ -86,6 +88,7 @@ export class ChannelFollowService { const targetAuth = await this.channelService.channelData.auth.getChannelAuthenticated({ userId, channelId: webhookChannelId, + viewer, }); const target = targetAuth.channel; if (!target.guildId || !CHANNEL_FOLLOW_TARGET_TYPES.has(target.type)) { @@ -173,12 +176,14 @@ export class ChannelFollowService { async getFollowerStats({ userId, + viewer, channelId, }: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; }): Promise { - const channel = await this.channelService.channelData.operations.getChannel({userId, channelId}); + const channel = await this.channelService.channelData.operations.getChannel({userId, viewer, channelId}); if (channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT) { throw new AnnouncementChannelRequiredError(); } diff --git a/fluxer_api/src/api/webhook/WebhookController.ts b/fluxer_api/src/api/webhook/WebhookController.ts index 18188d8b6..d82bca388 100644 --- a/fluxer_api/src/api/webhook/WebhookController.ts +++ b/fluxer_api/src/api/webhook/WebhookController.ts @@ -11,6 +11,7 @@ import { import type {MessageRequest} from '@app/api/channel/MessageTypes'; import {normalizeMessageRequestPayload} from '@app/api/channel/services/message/MessageRequestCompatibility'; import {parseMultipartMessageData} from '@app/api/channel/services/message/MessageRequestParser'; +import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate'; import {LoginRequired} from '@app/api/middleware/AuthMiddleware'; import {BlockAppOriginMiddleware} from '@app/api/middleware/BlockAppOriginMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; @@ -41,6 +42,7 @@ import { WebhookCreateRequest, WebhookExecuteQueryRequest, WebhookMessageEditRequest, + WebhookMessageQueryRequest, WebhookMessageRequest, WebhookMultipartMessageRequest, WebhookTokenUpdateRequest, @@ -54,23 +56,37 @@ import { WebhookTokenResponse, } from '@fluxer/schema/src/domains/webhook/WebhookSchemas'; import type {Context} from 'hono'; +import type {z} from 'zod'; -function validateWebhookMessagePayload(data: unknown): WebhookMessageRequest { - const validationResult = WebhookMessageRequest.safeParse(normalizeMessageRequestPayload(data)); +const FORUM_POST_FIELDS = {thread_name: true, applied_tags: true} as const; +const WebhookMessagePayload = WebhookMessageRequest.omit(FORUM_POST_FIELDS); +const WebhookMultipartMessagePayload = WebhookMultipartMessageRequest.omit(FORUM_POST_FIELDS); + +function validateWebhookMessagePayload(data: unknown): z.infer { + const validationResult = WebhookMessagePayload.safeParse(normalizeMessageRequestPayload(data)); if (!validationResult.success) { throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA); } return validationResult.data; } -function validateWebhookMultipartMessagePayload(data: unknown): WebhookMultipartMessageRequest { - const validationResult = WebhookMultipartMessageRequest.safeParse(normalizeMessageRequestPayload(data)); +function validateWebhookMultipartMessagePayload(data: unknown): z.infer { + const validationResult = WebhookMultipartMessagePayload.safeParse(normalizeMessageRequestPayload(data)); if (!validationResult.success) { throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA); } return validationResult.data; } +function pickForumPostFields(payload: unknown): Pick { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return {}; + const {thread_name, applied_tags} = payload as Record; + return { + ...(thread_name !== undefined ? {thread_name} : {}), + ...(applied_tags !== undefined ? {applied_tags} : {}), + }; +} + async function parseWebhookJsonMessageData(ctx: Context): Promise { let data: unknown; try { @@ -79,7 +95,7 @@ async function parseWebhookJsonMessageData(ctx: Context): Promise { const response = await ctx.get('webhookRequestService').listGuildWebhooks({ userId: ctx.get('user').id, + viewer: viewerFromCtx(ctx), guildId: createGuildID(ctx.req.valid('param').guild_id), requestCache: ctx.get('requestCache'), }); @@ -166,6 +184,7 @@ export function WebhookController(app: HonoApp) { Validator('param', ChannelIdParam), async (ctx) => { const response = await ctx.get('webhookRequestService').listChannelWebhooks({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, channelId: createChannelID(ctx.req.valid('param').channel_id), requestCache: ctx.get('requestCache'), @@ -193,6 +212,7 @@ export function WebhookController(app: HonoApp) { assertAccountNotLimited(ctx.get('user')); const auditLogReason = ctx.get('auditLogReason') ?? null; const response = await ctx.get('webhookRequestService').createWebhook({ + viewer: viewerFromCtx(ctx), userId: ctx.get('user').id, channelId: createChannelID(ctx.req.valid('param').channel_id), data: ctx.req.valid('json'), @@ -220,6 +240,7 @@ export function WebhookController(app: HonoApp) { async (ctx) => { const response = await ctx.get('webhookRequestService').getWebhook({ userId: ctx.get('user').id, + viewer: viewerFromCtx(ctx), webhookId: createWebhookID(ctx.req.valid('param').webhook_id), requestCache: ctx.get('requestCache'), }); @@ -246,6 +267,7 @@ export function WebhookController(app: HonoApp) { const auditLogReason = ctx.get('auditLogReason') ?? null; const response = await ctx.get('webhookRequestService').updateWebhook({ userId: ctx.get('user').id, + viewer: viewerFromCtx(ctx), webhookId: createWebhookID(ctx.req.valid('param').webhook_id), data: ctx.req.valid('json'), requestCache: ctx.get('requestCache'), @@ -273,6 +295,7 @@ export function WebhookController(app: HonoApp) { const auditLogReason = ctx.get('auditLogReason') ?? null; await ctx.get('webhookRequestService').deleteWebhook({ userId: ctx.get('user').id, + viewer: viewerFromCtx(ctx), webhookId: createWebhookID(ctx.req.valid('param').webhook_id), auditLogReason, }); @@ -376,6 +399,7 @@ export function WebhookController(app: HonoApp) { const response = await ctx.get('webhookRequestService').executeWebhook({ webhookId: createWebhookID(webhookId), token: createWebhookToken(token), + threadId: ctx.req.query('thread_id'), data, wait, requestCache: ctx.get('requestCache'), @@ -400,11 +424,13 @@ export function WebhookController(app: HonoApp) { tags: ['Webhooks'], }), Validator('param', WebhookIdTokenMessageIdParam), + Validator('query', WebhookMessageQueryRequest), async (ctx) => { const {webhook_id: webhookId, token, message_id: messageId} = ctx.req.valid('param'); const response = await ctx.get('webhookRequestService').getWebhookMessage({ webhookId: createWebhookID(webhookId), token: createWebhookToken(token), + threadId: ctx.req.query('thread_id'), messageId: createMessageID(messageId), requestCache: ctx.get('requestCache'), }); @@ -425,6 +451,7 @@ export function WebhookController(app: HonoApp) { tags: ['Webhooks'], }), Validator('param', WebhookIdTokenMessageIdParam), + Validator('query', WebhookMessageQueryRequest), Validator('json', WebhookMessageEditRequest), async (ctx) => { const {webhook_id: webhookId, token, message_id: messageId} = ctx.req.valid('param'); @@ -432,6 +459,7 @@ export function WebhookController(app: HonoApp) { const response = await ctx.get('webhookRequestService').editWebhookMessage({ webhookId: createWebhookID(webhookId), token: createWebhookToken(token), + threadId: ctx.req.query('thread_id'), messageId: createMessageID(messageId), data, requestCache: ctx.get('requestCache'), @@ -453,11 +481,13 @@ export function WebhookController(app: HonoApp) { tags: ['Webhooks'], }), Validator('param', WebhookIdTokenMessageIdParam), + Validator('query', WebhookMessageQueryRequest), async (ctx) => { const {webhook_id: webhookId, token, message_id: messageId} = ctx.req.valid('param'); await ctx.get('webhookRequestService').deleteWebhookMessage({ webhookId: createWebhookID(webhookId), token: createWebhookToken(token), + threadId: ctx.req.query('thread_id'), messageId: createMessageID(messageId), requestCache: ctx.get('requestCache'), }); @@ -478,12 +508,14 @@ export function WebhookController(app: HonoApp) { tags: ['Webhooks'], }), Validator('param', WebhookIdTokenParam), + Validator('query', WebhookMessageQueryRequest), Validator('json', GitHubWebhook), async (ctx) => { const {webhook_id: webhookId, token} = ctx.req.valid('param'); await ctx.get('webhookRequestService').executeGitHubWebhook({ webhookId: createWebhookID(webhookId), token: createWebhookToken(token), + threadId: ctx.req.query('thread_id'), event: ctx.req.header('X-GitHub-Event') ?? '', delivery: ctx.req.header('X-GitHub-Delivery') ?? '', data: ctx.req.valid('json'), @@ -506,12 +538,14 @@ export function WebhookController(app: HonoApp) { tags: ['Webhooks'], }), Validator('param', WebhookIdTokenParam), + Validator('query', WebhookMessageQueryRequest), Validator('json', SlackWebhookRequest), async (ctx) => { const {webhook_id: webhookId, token} = ctx.req.valid('param'); await ctx.get('webhookRequestService').executeSlackWebhook({ webhookId: createWebhookID(webhookId), token: createWebhookToken(token), + threadId: ctx.req.query('thread_id'), data: ctx.req.valid('json'), requestCache: ctx.get('requestCache'), }); diff --git a/fluxer_api/src/api/webhook/WebhookRequestService.ts b/fluxer_api/src/api/webhook/WebhookRequestService.ts index d23b265e2..d5e8e1265 100644 --- a/fluxer_api/src/api/webhook/WebhookRequestService.ts +++ b/fluxer_api/src/api/webhook/WebhookRequestService.ts @@ -6,6 +6,8 @@ import {Config} from '@app/api/Config'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import type {MessageUpdateRequest} from '@app/api/channel/MessageTypes'; import {createMessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService'; +import {maskThreadArtifactsFor} from '@app/api/channel/services/message/ThreadMessageResponses'; +import {SYSTEM_THREAD_VIEWER, type ThreadViewer} from '@app/api/experiment/ChannelThreadsGate'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {LiveKitWebhookService} from '@app/api/infrastructure/LiveKitWebhookService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; @@ -36,18 +38,21 @@ type WebhookExecutionResponse = MessageResponse | null; interface WebhookListGuildParams { userId: UserID; + viewer: ThreadViewer; guildId: GuildID; requestCache: RequestCache; } interface WebhookListChannelParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; requestCache: RequestCache; } interface WebhookCreateParams { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; data: WebhookCreateRequest; requestCache: RequestCache; @@ -56,6 +61,7 @@ interface WebhookCreateParams { interface WebhookGetByUserParams { userId: UserID; + viewer: ThreadViewer; webhookId: WebhookID; requestCache: RequestCache; } @@ -70,6 +76,7 @@ type WebhookGetParams = WebhookGetByUserParams | WebhookGetByTokenParams; interface WebhookUpdateByUserParams { userId: UserID; + viewer: ThreadViewer; webhookId: WebhookID; data: WebhookUpdateRequest; requestCache: RequestCache; @@ -87,6 +94,7 @@ type WebhookUpdateParams = WebhookUpdateByUserParams | WebhookUpdateByTokenParam interface WebhookDeleteByUserParams { userId: UserID; + viewer: ThreadViewer; webhookId: WebhookID; auditLogReason?: string | null; } @@ -101,6 +109,7 @@ type WebhookDeleteParams = WebhookDeleteByUserParams | WebhookDeleteByTokenParam interface WebhookExecuteParams { webhookId: WebhookID; token: WebhookToken; + threadId?: string; data: WebhookExecuteMessageData; wait: boolean; requestCache: RequestCache; @@ -109,6 +118,7 @@ interface WebhookExecuteParams { interface WebhookGetMessageParams { webhookId: WebhookID; token: WebhookToken; + threadId?: string; messageId: MessageID; requestCache: RequestCache; } @@ -116,6 +126,7 @@ interface WebhookGetMessageParams { interface WebhookEditMessageParams { webhookId: WebhookID; token: WebhookToken; + threadId?: string; messageId: MessageID; data: MessageUpdateRequest; requestCache: RequestCache; @@ -124,6 +135,7 @@ interface WebhookEditMessageParams { interface WebhookDeleteMessageParams { webhookId: WebhookID; token: WebhookToken; + threadId?: string; messageId: MessageID; requestCache: RequestCache; } @@ -131,6 +143,7 @@ interface WebhookDeleteMessageParams { interface WebhookExecuteGitHubParams { webhookId: WebhookID; token: WebhookToken; + threadId?: string; event: string; delivery: string; data: GitHubWebhook; @@ -140,6 +153,7 @@ interface WebhookExecuteGitHubParams { interface WebhookExecuteSlackParams { webhookId: WebhookID; token: WebhookToken; + threadId?: string; data: SlackWebhookRequest; requestCache: RequestCache; } @@ -184,6 +198,7 @@ export class WebhookRequestService { async listGuildWebhooks(params: WebhookListGuildParams): Promise> { const webhooks = await this.webhookService.getGuildWebhooks({ userId: params.userId, + viewer: params.viewer, guildId: params.guildId, }); return mapWebhooksToResponse({ @@ -197,6 +212,7 @@ export class WebhookRequestService { async listChannelWebhooks(params: WebhookListChannelParams): Promise> { const webhooks = await this.webhookService.getChannelWebhooks({ userId: params.userId, + viewer: params.viewer, channelId: params.channelId, }); return mapWebhooksToResponse({ @@ -211,6 +227,7 @@ export class WebhookRequestService { const webhook = await this.webhookService.createWebhook( { userId: params.userId, + viewer: params.viewer, channelId: params.channelId, data: params.data, }, @@ -230,7 +247,11 @@ export class WebhookRequestService { const webhook = await this.webhookService.getWebhookByToken({webhookId: params.webhookId, token: params.token}); return mapWebhookToTokenResponse(webhook); } - const webhook = await this.webhookService.getWebhook({userId: params.userId, webhookId: params.webhookId}); + const webhook = await this.webhookService.getWebhook({ + userId: params.userId, + viewer: params.viewer, + webhookId: params.webhookId, + }); return mapWebhookToResponseWithCache({ webhook, userCacheService: this.userCacheService, @@ -253,6 +274,7 @@ export class WebhookRequestService { const webhook = await this.webhookService.updateWebhook( { userId: params.userId, + viewer: params.viewer, webhookId: params.webhookId, data: params.data, }, @@ -274,6 +296,7 @@ export class WebhookRequestService { await this.webhookService.deleteWebhook( { userId: params.userId, + viewer: params.viewer, webhookId: params.webhookId, }, params.auditLogReason ?? null, @@ -284,6 +307,7 @@ export class WebhookRequestService { const message = await this.webhookService.executeWebhook({ webhookId: params.webhookId, token: params.token, + threadId: params.threadId, data: params.data, requestCache: params.requestCache, }); @@ -297,6 +321,7 @@ export class WebhookRequestService { const message = await this.webhookService.getWebhookMessage({ webhookId: params.webhookId, token: params.token, + threadId: params.threadId, messageId: params.messageId, }); return this.mapMessageResponse(message, params.requestCache); @@ -306,6 +331,7 @@ export class WebhookRequestService { const message = await this.webhookService.editWebhookMessage({ webhookId: params.webhookId, token: params.token, + threadId: params.threadId, messageId: params.messageId, data: params.data, requestCache: params.requestCache, @@ -317,6 +343,7 @@ export class WebhookRequestService { await this.webhookService.deleteWebhookMessage({ webhookId: params.webhookId, token: params.token, + threadId: params.threadId, messageId: params.messageId, requestCache: params.requestCache, }); @@ -326,6 +353,7 @@ export class WebhookRequestService { await this.webhookService.executeGitHubWebhook({ webhookId: params.webhookId, token: params.token, + threadId: params.threadId, event: params.event, delivery: params.delivery, data: params.data, @@ -346,8 +374,10 @@ export class WebhookRequestService { await this.webhookService.executeWebhook({ webhookId: params.webhookId, token: params.token, + threadId: params.threadId, data: transformSlackWebhookRequest(params.data), requestCache: params.requestCache, + service: true, }); } @@ -381,7 +411,7 @@ export class WebhookRequestService { private async mapMessageResponse(message: Message, _requestCache: RequestCache): Promise { const channel = await this.channelRepository.findUnique(message.channelId); - return createMessageResponseDataService().buildMessage({ + const response = await createMessageResponseDataService().buildMessage({ userId: createUserID(0n), message, access: { @@ -390,5 +420,7 @@ export class WebhookRequestService { canReadMessageHistory: true, }, }); + const [shaped] = maskThreadArtifactsFor(SYSTEM_THREAD_VIEWER, channel?.guildId ?? null, [response]); + return shaped ?? response; } } diff --git a/fluxer_api/src/api/webhook/WebhookService.ts b/fluxer_api/src/api/webhook/WebhookService.ts index 579677f13..cd84c669b 100644 --- a/fluxer_api/src/api/webhook/WebhookService.ts +++ b/fluxer_api/src/api/webhook/WebhookService.ts @@ -2,18 +2,27 @@ import {stripOwnAttachmentSignature} from '@app/api/attachment/AttachmentUrls'; import type {ChannelID, GuildID, MessageID, UserID, WebhookID, WebhookToken} from '@app/api/BrandedTypes'; -import {createChannelID, createGuildID, createWebhookID, createWebhookToken} from '@app/api/BrandedTypes'; +import {createChannelID, createGuildID, createUserID, createWebhookID, createWebhookToken} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import type {MessageRequest, MessageUpdateRequest} from '@app/api/channel/MessageTypes'; import {withChannelFollowLock} from '@app/api/channel/services/ChannelFollowers'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; +import type {ThreadService} from '@app/api/channel/services/thread/ThreadService'; +import {webhookThreadIdRefusedTotal} from '@app/api/channel/threads/ThreadMetrics'; import {assertCrosspostContentRules} from '@app/api/channel/utils/CrosspostContentRules'; import { type ContentWarningChannelLike, channelToContentWarningView, guildResponseToContentWarningView, } from '@app/api/channel/utils/EffectiveContentWarning'; +import { + everEnabled, + guildActive, + isTainted, + type ThreadViewer, + viewerActive, +} from '@app/api/experiment/ChannelThreadsGate'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {GuildService} from '@app/api/guild/services/GuildService'; import type {AvatarService} from '@app/api/infrastructure/AvatarService'; @@ -30,6 +39,7 @@ import type {Channel} from '@app/api/models/Channel'; import type {Message} from '@app/api/models/Message'; import type {Webhook} from '@app/api/models/Webhook'; import * as RandomUtils from '@app/api/utils/RandomUtils'; +import {inputValidationErrorFromZodIssues} from '@app/api/Validator'; import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository'; import {transform as GitHubTransform} from '@app/api/webhook/transformers/GitHubTransformer'; import {instatusDeliveryKey, transformInstatusWebhook} from '@app/api/webhook/transformers/InstatusTransformer'; @@ -53,30 +63,48 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError'; import {MaxWebhooksPerGuildError} from '@fluxer/errors/src/domains/guild/MaxWebhooksPerGuildError'; import {UnknownWebhookError} from '@fluxer/errors/src/domains/webhook/UnknownWebhookError'; +import {WebhookForumTargetConflictError} from '@fluxer/errors/src/domains/webhook/WebhookForumTargetConflictError'; +import {WebhookForumTargetRequiredError} from '@fluxer/errors/src/domains/webhook/WebhookForumTargetRequiredError'; +import {WebhookServiceForumUnsupportedError} from '@fluxer/errors/src/domains/webhook/WebhookServiceForumUnsupportedError'; +import {WebhookThreadNameRequiresForumError} from '@fluxer/errors/src/domains/webhook/WebhookThreadNameRequiresForumError'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas'; import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas'; import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas'; -import type { - WebhookCreateRequest, - WebhookMessageRequest, - WebhookTokenUpdateRequest, - WebhookUpdateRequest, +import { + type WebhookCreateRequest, + WebhookForumPostRequestFields, + type WebhookMessageRequest, + type WebhookTokenUpdateRequest, + type WebhookUpdateRequest, } from '@fluxer/schema/src/domains/webhook/WebhookRequestSchemas'; import type {ICacheService} from '@pkgs/cache/src/ICacheService'; import {seconds} from 'itty-time'; +import {z} from 'zod'; -export interface WebhookExecuteMessageData extends Omit { +export interface WebhookExecuteMessageData + extends Omit { attachments?: WebhookMessageRequest['attachments'] | MessageRequest['attachments']; username?: string | null; avatar_url?: string | null; + thread_name?: unknown; + applied_tags?: unknown; } +const WebhookForumPostFields = z.object(WebhookForumPostRequestFields); + +type WebhookForumPost = z.infer; + interface WebhookUserParams { userId: UserID; + viewer: ThreadViewer; webhookId: WebhookID; } +interface WebhookThreadParams { + threadId?: string; +} + interface WebhookTokenParams { webhookId: WebhookID; token: WebhookToken; @@ -86,12 +114,13 @@ interface WebhookTokenUpdateParams extends WebhookTokenParams { data: WebhookTokenUpdateRequest; } -interface WebhookExecuteParams extends WebhookTokenParams { +interface WebhookExecuteParams extends WebhookTokenParams, WebhookThreadParams { data: WebhookExecuteMessageData; requestCache: RequestCache; + service?: boolean; } -interface WebhookMessageLookupParams extends WebhookTokenParams { +interface WebhookMessageLookupParams extends WebhookTokenParams, WebhookThreadParams { messageId: MessageID; } @@ -103,7 +132,7 @@ interface WebhookMessageUpdateParams extends WebhookMessageParams { data: MessageUpdateRequest; } -interface WebhookExecuteGitHubParams extends WebhookTokenParams { +interface WebhookExecuteGitHubParams extends WebhookTokenParams, WebhookThreadParams { event: string; delivery: string; data: GitHubWebhook; @@ -152,17 +181,26 @@ export class WebhookService { private snowflakeService: ISnowflakeService, private readonly guildAuditLogService: GuildAuditLogService, private readonly limitConfigService: LimitConfigService, + private readonly threadService?: () => ThreadService, ) {} - async getWebhook({userId, webhookId}: WebhookUserParams): Promise { - return this.getAuthenticatedWebhook({userId, webhookId}); + async getWebhook(params: WebhookUserParams): Promise { + return this.getAuthenticatedWebhook(params); } async getWebhookByToken(params: WebhookTokenParams): Promise { return this.getTokenAuthenticatedWebhook(params); } - async getGuildWebhooks({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise> { + async getGuildWebhooks({ + userId, + guildId, + viewer, + }: { + userId: UserID; + guildId: GuildID; + viewer: ThreadViewer; + }): Promise> { const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId}); await checkPermission(Permissions.MANAGE_WEBHOOKS); const webhooks = await this.repository.listByGuild(guildId); @@ -173,11 +211,23 @@ export class WebhookService { : Promise.resolve(false), ), ); - return webhooks.filter((_webhook, index) => visibility[index]); + return this.withoutHiddenForumWebhooks( + guildId, + viewer, + webhooks.filter((_webhook, index) => visibility[index]), + ); } - async getChannelWebhooks({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise> { - const channel = await this.channelService.channelData.operations.getChannel({userId, channelId}); + async getChannelWebhooks({ + userId, + channelId, + viewer, + }: { + userId: UserID; + viewer: ThreadViewer; + channelId: ChannelID; + }): Promise> { + const channel = await this.channelService.channelData.operations.getChannel({userId, viewer, channelId}); this.assertWebhookTargetChannel(channel); const {checkPermission} = await this.guildService.getGuildAuthenticated({ userId, @@ -191,13 +241,14 @@ export class WebhookService { async createWebhook( params: { userId: UserID; + viewer: ThreadViewer; channelId: ChannelID; data: WebhookCreateRequest; }, auditLogReason?: string | null, ): Promise { - const {userId, channelId, data} = params; - const channel = await this.channelService.channelData.operations.getChannel({userId, channelId}); + const {userId, viewer, channelId, data} = params; + const channel = await this.channelService.channelData.operations.getChannel({userId, viewer, channelId}); this.assertWebhookTargetChannel(channel); const {checkPermission, guildData} = await this.guildService.getGuildAuthenticated({ userId, @@ -238,13 +289,14 @@ export class WebhookService { async updateWebhook( params: { userId: UserID; + viewer: ThreadViewer; webhookId: WebhookID; data: WebhookUpdateRequest; }, auditLogReason?: string | null, ): Promise { - const {userId, webhookId, data} = params; - const webhook = await this.getAuthenticatedWebhook({userId, webhookId}); + const {userId, viewer, webhookId, data} = params; + const webhook = await this.getAuthenticatedWebhook({userId, viewer, webhookId}); const {checkPermission, guildData} = await this.guildService.getGuildAuthenticated({ userId, guildId: webhook.guildId ? webhook.guildId : createGuildID(0n), @@ -258,6 +310,7 @@ export class WebhookService { if (data.channel_id && data.channel_id !== webhook.channelId) { const targetChannel = await this.channelService.channelData.operations.getChannel({ userId, + viewer, channelId: createChannelID(data.channel_id), }); if (isFollower && !CHANNEL_FOLLOW_TARGET_TYPES.has(targetChannel.type)) { @@ -353,17 +406,8 @@ export class WebhookService { return updatedWebhook; } - async deleteWebhook( - { - userId, - webhookId, - }: { - userId: UserID; - webhookId: WebhookID; - }, - auditLogReason?: string | null, - ): Promise { - const webhook = await this.getAuthenticatedWebhook({userId, webhookId}); + async deleteWebhook({userId, viewer, webhookId}: WebhookUserParams, auditLogReason?: string | null): Promise { + const webhook = await this.getAuthenticatedWebhook({userId, viewer, webhookId}); const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: webhook.guildId!}); await checkPermission(Permissions.MANAGE_WEBHOOKS); await this.repository.delete(webhookId); @@ -391,12 +435,61 @@ export class WebhookService { }); } - async executeWebhook({webhookId, token, data, requestCache}: WebhookExecuteParams): Promise { - const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); - await this.assertWebhookGuildChannel(webhook); + async executeWebhook({ + webhookId, + token, + threadId, + data, + requestCache, + service, + }: WebhookExecuteParams): Promise { + const {webhook, channel} = await this.getTokenAuthenticatedWebhookChannel({webhookId, token}); + const forumPost = this.resolveForumPost(channel, data); + if (channel.isThreadOnly()) { + if (service && threadId === undefined) throw new WebhookServiceForumUnsupportedError(); + if (threadId !== undefined && forumPost?.thread_name !== undefined) throw new WebhookForumTargetConflictError(); + if (threadId === undefined && forumPost?.thread_name === undefined) throw new WebhookForumTargetRequiredError(); + } + const thread = await this.resolveWebhookThread(webhook, threadId); const attachments = data.attachments?.filter((attachment) => this.isUploadedAttachmentData(attachment)); + const avatar = data.avatar_url + ? await this.getWebhookAvatar({webhookId: webhook.id, avatarUrl: data.avatar_url}) + : null; + const messageData: MessageRequest = { + content: data.content, + embeds: data.embeds, + attachments, + message_reference: data.message_reference, + allowed_mentions: data.allowed_mentions ?? WebhookService.NO_ALLOWED_MENTIONS, + flags: data.flags, + nonce: data.nonce, + favorite_meme_id: data.favorite_meme_id, + sticker_ids: data.sticker_ids, + tts: data.tts, + }; + const threadName = forumPost?.thread_name; + if (threadName !== undefined && !thread) { + await this.channelService.messages.send.validateWebhookForumStarter({parent: channel, data: messageData}); + return this.createThreadService().creation.createWebhookForumPost({ + webhookId: createUserID(BigInt(webhook.id)), + parent: channel, + name: threadName, + appliedTags: (forumPost?.applied_tags ?? []).map((id) => BigInt(id)), + sendStarter: (created) => + this.channelService.messages.send.sendWebhookMessage({ + webhook, + thread: created, + data: messageData, + username: data.username, + avatar, + requestCache, + forumStarter: true, + }), + }); + } return this.channelService.messages.send.sendWebhookMessage({ webhook, + thread, data: { content: data.content, embeds: data.embeds, @@ -410,49 +503,71 @@ export class WebhookService { tts: data.tts, }, username: data.username, - avatar: data.avatar_url ? await this.getWebhookAvatar({webhookId: webhook.id, avatarUrl: data.avatar_url}) : null, + avatar, requestCache, }); } + private resolveForumPost(channel: Channel, data: WebhookExecuteMessageData): WebhookForumPost | null { + if (data.thread_name === undefined && data.applied_tags === undefined) return null; + if (!guildActive(channel.guildId!)) return null; + if (!channel.isThreadOnly()) throw new WebhookThreadNameRequiresForumError(); + const parsed = WebhookForumPostFields.safeParse({thread_name: data.thread_name, applied_tags: data.applied_tags}); + if (!parsed.success) throw inputValidationErrorFromZodIssues(parsed.error.issues); + return parsed.data; + } + async editWebhookMessage({ webhookId, token, + threadId, messageId, data, requestCache, }: WebhookMessageUpdateParams): Promise { const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); + const thread = await this.resolveWebhookThread(webhook, threadId); return this.channelService.messages.send.editWebhookMessage({ webhook, + thread, messageId, data, requestCache, }); } - async deleteWebhookMessage({webhookId, token, messageId, requestCache}: WebhookMessageParams): Promise { + async deleteWebhookMessage({ + webhookId, + token, + threadId, + messageId, + requestCache, + }: WebhookMessageParams): Promise { const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); + const thread = await this.resolveWebhookThread(webhook, threadId); await this.channelService.messages.deletion.deleteWebhookMessage({ webhook, + thread, messageId, requestCache, }); } - async getWebhookMessage({webhookId, token, messageId}: WebhookMessageLookupParams): Promise { + async getWebhookMessage({webhookId, token, threadId, messageId}: WebhookMessageLookupParams): Promise { const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); if (!webhook.channelId) throw new UnknownChannelError(); - const message = await this.channelRepository.getMessage(webhook.channelId, messageId); + const thread = await this.resolveWebhookThread(webhook, threadId); + const message = await this.channelRepository.getMessage(thread?.id ?? webhook.channelId, messageId); if (!message) throw new UnknownMessageError(); if (message.webhookId !== webhook.id) throw new MissingPermissionsError(); return message; } async executeGitHubWebhook(params: WebhookExecuteGitHubParams): Promise { - const {webhookId, token, event, delivery, data, requestCache} = params; - const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); - await this.assertWebhookGuildChannel(webhook); + const {webhookId, token, threadId, event, delivery, data, requestCache} = params; + const {webhook, channel} = await this.getTokenAuthenticatedWebhookChannel({webhookId, token}); + if (channel.isThreadOnly() && threadId === undefined) throw new WebhookServiceForumUnsupportedError(); + const thread = await this.resolveWebhookThread(webhook, threadId); if (delivery) { const isCached = await this.cacheService.get(`github:${webhookId}:${delivery}`); if (isCached) return; @@ -461,6 +576,7 @@ export class WebhookService { if (!embed) return; await this.channelService.messages.send.sendWebhookMessage({ webhook, + thread, data: {embeds: [embed], allowed_mentions: WebhookService.NO_ALLOWED_MENTIONS}, username: 'GitHub', avatar: await this.getGitHubWebhookAvatar(webhook.id), @@ -471,8 +587,8 @@ export class WebhookService { async executeInstatusWebhook(params: WebhookExecuteInstatusParams): Promise { const {webhookId, token, data, requestCache} = params; - const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); - await this.assertWebhookGuildChannel(webhook); + const {webhook, channel} = await this.getTokenAuthenticatedWebhookChannel({webhookId, token}); + if (channel.isThreadOnly()) throw new WebhookServiceForumUnsupportedError(); const delivery = instatusDeliveryKey(data); if (delivery) { const isCached = await this.cacheService.get(`instatus:${webhookId}:${delivery}`); @@ -506,11 +622,14 @@ export class WebhookService { } } - private async getAuthenticatedWebhook({userId, webhookId}: WebhookUserParams): Promise { + private async getAuthenticatedWebhook({userId, viewer, webhookId}: WebhookUserParams): Promise { const webhook = await this.repository.findUnique(webhookId); if (!webhook) throw new UnknownWebhookError(); const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: webhook.guildId!}); await checkPermission(Permissions.MANAGE_WEBHOOKS); + if (webhook.guildId && (await this.withoutHiddenForumWebhooks(webhook.guildId, viewer, [webhook])).length === 0) { + throw new UnknownWebhookError(); + } if (webhook.guildId && webhook.channelId) { await this.assertChannelWebhookPermission({ userId, @@ -642,20 +761,73 @@ export class WebhookService { private async getTokenAuthenticatedWebhook({webhookId, token}: WebhookTokenParams): Promise { const webhook = await this.repository.findByToken(webhookId, token); if (!webhook || webhook.type !== WebhookTypes.INCOMING) throw new UnknownWebhookError(); + if (await this.isDormantForumWebhook(webhook)) throw new UnknownWebhookError(); return webhook; } - private async assertWebhookGuildChannel(webhook: Webhook): Promise { + private async isDormantForumWebhook(webhook: Webhook): Promise { + if (!everEnabled() || !webhook.guildId || !webhook.channelId || guildActive(webhook.guildId)) return false; + if (!(await isTainted(webhook.guildId))) return false; + const channel = await this.channelRepository.findUnique(webhook.channelId); + return channel?.isThreadOnly() ?? false; + } + + private async withoutHiddenForumWebhooks( + guildId: GuildID, + viewer: ThreadViewer, + webhooks: Array, + ): Promise> { + if (!everEnabled() || viewerActive(viewer, guildId) || !(await isTainted(guildId))) return webhooks; + const channelIds = [...new Set(webhooks.flatMap((webhook) => (webhook.channelId ? [webhook.channelId] : [])))]; + if (channelIds.length === 0) return webhooks; + const forumIds = new Set( + (await this.channelRepository.listChannels(channelIds)) + .filter((channel) => channel.isThreadOnly()) + .map((channel) => channel.id), + ); + if (forumIds.size === 0) return webhooks; + return webhooks.filter((webhook) => !webhook.channelId || !forumIds.has(webhook.channelId)); + } + + private createThreadService(): ThreadService { + if (!this.threadService) throw new UnknownChannelError(); + return this.threadService(); + } + + private async resolveWebhookThread(webhook: Webhook, threadId: string | undefined): Promise { + if (threadId === undefined || !everEnabled() || !webhook.guildId || !webhook.channelId) return null; + if (!guildActive(webhook.guildId)) { + if (!(await isTainted(webhook.guildId))) return null; + webhookThreadIdRefusedTotal.inc(); + throw new UnknownChannelError(); + } + if (!/^\d{1,20}$/.test(threadId)) throw new UnknownChannelError(); + const thread = await this.channelRepository.findUnique(createChannelID(BigInt(threadId))); + if (!thread?.isThread() || thread.parentId !== webhook.channelId || thread.guildId !== webhook.guildId) { + throw new UnknownChannelError(); + } + return thread; + } + + private async getTokenAuthenticatedWebhookChannel({ + webhookId, + token, + }: WebhookTokenParams): Promise<{webhook: Webhook; channel: Channel & {guildId: GuildID}}> { + const webhook = await this.repository.findByToken(webhookId, token); + if (!webhook || webhook.type !== WebhookTypes.INCOMING) throw new UnknownWebhookError(); if (!webhook.channelId) throw new UnknownChannelError(); const channel = await this.channelRepository.findUnique(webhook.channelId); if (!channel) throw new UnknownChannelError(); + if (channel.isThreadOnly() && webhook.guildId && !guildActive(webhook.guildId)) throw new UnknownWebhookError(); this.assertWebhookTargetChannel(channel); + return {webhook, channel}; } private assertWebhookTargetChannel(channel: Channel): asserts channel is Channel & {guildId: GuildID} { - if (!channel.guildId || !GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)) { - throw new UnknownChannelError(); - } + if (!channel.guildId) throw new UnknownChannelError(); + if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)) return; + if (channel.isThreadOnly() && guildActive(channel.guildId)) return; + throw new UnknownChannelError(); } private async updateWebhookData({webhook, data}: {webhook: Webhook; data: WebhookUpdateRequest}): Promise<{ diff --git a/fluxer_api/src/api/webhook/tests/WebhookForumThreads.test.ts b/fluxer_api/src/api/webhook/tests/WebhookForumThreads.test.ts new file mode 100644 index 000000000..697948cd1 --- /dev/null +++ b/fluxer_api/src/api/webhook/tests/WebhookForumThreads.test.ts @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {MessageSendService} from '@app/api/channel/services/message/MessageSendService'; +import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {GuildAuditLogListResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import type {WebhookResponse} from '@fluxer/schema/src/domains/webhook/WebhookSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface Setup { + owner: TestAccount; + guildId: string; + forum: ChannelResponse; + webhook: WebhookResponse; +} + +describe('webhooks on forum and media channels', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + }); + + afterAll(async () => { + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(type: number = ChannelTypes.GUILD_FORUM): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + await ensureSessionStarted(harness, owner.token); + const guild = await createGuild(harness, owner.token, 'webhook forums'); + const forum = await threadsRequest(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({name: 'forum', type, available_tags: [{name: 'open'}, {name: 'staff', moderated: true}]}) + .execute(); + const webhook = await threadsRequest(harness, owner.token) + .post(`/channels/${forum.id}/webhooks`) + .body({name: 'poster'}) + .execute(); + return {owner, guildId: guild.id, forum, webhook}; + } + + function execute(s: Setup, body: Record, query = '?wait=true') { + return createBuilderWithoutAuth(harness) + .post(`/webhooks/${s.webhook.id}/${s.webhook.token}${query}`) + .body(body); + } + + for (const type of [ChannelTypes.GUILD_FORUM, ChannelTypes.GUILD_MEDIA]) { + it(`creates a post from thread_name in channel type ${type}`, async () => { + const s = await setup(type); + const [open] = s.forum.available_tags!; + const message = await execute(s, {content: 'hi', thread_name: 'from hook', applied_tags: [open!.id]}).execute(); + const thread = await threadsRequest(harness, s.owner.token) + .get(`/channels/${message.channel_id}`) + .execute(); + expect(message.id).toBe(thread.id); + expect(thread).toMatchObject({ + type: ChannelTypes.PUBLIC_THREAD, + parent_id: s.forum.id, + name: 'from hook', + owner_id: s.webhook.id, + applied_tags: [open!.id], + }); + const reply = await execute(s, {content: 'again'}, `?wait=true&thread_id=${thread.id}`).execute(); + expect(reply.channel_id).toBe(thread.id); + }); + } + + it('rejects an invalid starter before creating the post', async () => { + const s = await setup(); + const dispatched: Array = []; + const spy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild').mockImplementation(async (params) => { + dispatched.push(params.event); + }); + await execute(s, {thread_name: 'empty'}).expect(400, APIErrorCodes.CANNOT_SEND_EMPTY_MESSAGE).execute(); + spy.mockRestore(); + expect(dispatched).not.toContain('THREAD_CREATE'); + const forum = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.forum.id}`) + .execute(); + expect(forum.last_message_id ?? null).toBeNull(); + }); + + it('rolls clients back to the previous forum last_message_id when the starter fails', async () => { + const s = await setup(); + const first = await execute(s, {content: 'first', thread_name: 'first'}).execute(); + const dispatched: Array<{event: string; data: unknown}> = []; + const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild').mockImplementation(async (params) => { + dispatched.push({event: params.event, data: params.data}); + }); + const send = vi + .spyOn(MessageSendService.prototype, 'sendWebhookMessage') + .mockRejectedValueOnce(new Error('starter failed')); + await execute(s, {content: 'second', thread_name: 'second'}).expect(500).execute(); + send.mockRestore(); + dispatch.mockRestore(); + const events = dispatched.map((entry) => entry.event); + expect(events).toEqual(['THREAD_CREATE', 'CHANNEL_UPDATE', 'THREAD_DELETE']); + expect(dispatched[1]!.data).toMatchObject({id: s.forum.id, last_message_id: first.channel_id}); + const forum = await threadsRequest(harness, s.owner.token) + .get(`/channels/${s.forum.id}`) + .execute(); + expect(forum.last_message_id).toBe(first.channel_id); + }); + + it('enforces the forum target rules', async () => { + const s = await setup(); + const [, staff] = s.forum.available_tags!; + await execute(s, {content: 'x'}).expect(400, APIErrorCodes.WEBHOOK_FORUM_TARGET_REQUIRED).execute(); + const message = await execute(s, {content: 'x', thread_name: 'post'}).execute(); + await execute(s, {content: 'x', thread_name: 'post'}, `?thread_id=${message.channel_id}`) + .expect(400, APIErrorCodes.WEBHOOK_FORUM_TARGET_CONFLICT) + .execute(); + await execute(s, {content: 'x', thread_name: 'post', applied_tags: [staff!.id]}) + .expect(403) + .execute(); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${s.webhook.id}/${s.webhook.token}/slack`) + .body({text: 'hi'}) + .expect(400, APIErrorCodes.WEBHOOK_SERVICE_FORUM_UNSUPPORTED) + .execute(); + const text = await createChannel(harness, s.owner.token, s.guildId, 'text'); + const textHook = await threadsRequest(harness, s.owner.token) + .post(`/channels/${text.id}/webhooks`) + .body({name: 'text'}) + .execute(); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${textHook.id}/${textHook.token}?wait=true`) + .body({content: 'x', thread_name: 'nope'}) + .expect(400, APIErrorCodes.WEBHOOK_THREAD_NAME_REQUIRES_FORUM) + .execute(); + }); + + it('omits a webhook moved onto a forum from the audit log webhooks for non-viewers', async () => { + const s = await setup(); + const text = await createChannel(harness, s.owner.token, s.guildId, 'text'); + const hook = await threadsRequest(harness, s.owner.token) + .post(`/channels/${text.id}/webhooks`) + .body({name: 'mover'}) + .execute(); + await threadsRequest(harness, s.owner.token).patch(`/webhooks/${hook.id}`).body({channel_id: s.forum.id}).execute(); + const controlLog = await threadsRequest(harness, s.owner.token, {capable: false}) + .get(`/guilds/${s.guildId}/audit-logs`) + .execute(); + expect(controlLog.audit_log_entries.some((entry) => entry.target_id === hook.id)).toBe(true); + expect(controlLog.webhooks.map((webhook) => webhook.id)).not.toContain(hook.id); + expect(controlLog.webhooks.some((webhook) => webhook.channel_id === s.forum.id)).toBe(false); + }); + + it('hides forum webhooks once the guild leaves the experiment and ignores forum fields outside it', async () => { + const s = await setup(); + await setChannelThreadsConfig({enabled: false}); + await createBuilderWithoutAuth(harness) + .get(`/webhooks/${s.webhook.id}/${s.webhook.token}`) + .expect(404, APIErrorCodes.UNKNOWN_WEBHOOK) + .execute(); + for (const capable of [true, false]) { + await threadsRequest(harness, s.owner.token, {capable}) + .get(`/webhooks/${s.webhook.id}`) + .expect(404, APIErrorCodes.UNKNOWN_WEBHOOK) + .execute(); + await threadsRequest(harness, s.owner.token, {capable}) + .patch(`/webhooks/${s.webhook.id}`) + .body({name: 'renamed'}) + .expect(404, APIErrorCodes.UNKNOWN_WEBHOOK) + .execute(); + await threadsRequest(harness, s.owner.token, {capable}) + .delete(`/webhooks/${s.webhook.id}`) + .expect(404, APIErrorCodes.UNKNOWN_WEBHOOK) + .execute(); + } + await execute(s, {content: 'x', thread_name: 'post'}).expect(404, APIErrorCodes.UNKNOWN_WEBHOOK).execute(); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${s.webhook.id}/${s.webhook.token}/slack`) + .body({text: 'hi'}) + .expect(404, APIErrorCodes.UNKNOWN_WEBHOOK) + .execute(); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'plain'); + const hook = await threadsRequest(harness, owner.token) + .post(`/channels/${guild.system_channel_id}/webhooks`) + .body({name: 'plain'}) + .execute(); + const message = await createBuilderWithoutAuth(harness) + .post(`/webhooks/${hook.id}/${hook.token}?wait=true`) + .body({content: 'x', thread_name: 'ignored', applied_tags: ['1']}) + .execute(); + expect(message.channel_id).toBe(guild.system_channel_id); + }); + + it('reads no channels for webhook lookups in an untainted guild after the experiment was enabled', async () => { + await setChannelThreadsConfig({enabled: true, guild_basis_points: 0, user_basis_points: 10000}); + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'control'); + const hook = await threadsRequest(harness, owner.token) + .post(`/channels/${guild.system_channel_id}/webhooks`) + .body({name: 'control'}) + .execute(); + const findUnique = vi.spyOn(ChannelRepository.prototype, 'findUnique'); + const listChannels = vi.spyOn(ChannelRepository.prototype, 'listChannels'); + await createBuilderWithoutAuth(harness).get(`/webhooks/${hook.id}/${hook.token}`).execute(); + await threadsRequest(harness, owner.token).get(`/guilds/${guild.id}/webhooks`).execute(); + await threadsRequest(harness, owner.token).get(`/webhooks/${hook.id}`).execute(); + const readChannelIds = findUnique.mock.calls.map(([channelId]) => channelId.toString()); + const listCalls = listChannels.mock.calls.length; + findUnique.mockRestore(); + listChannels.mockRestore(); + expect(readChannelIds).not.toContain(guild.system_channel_id); + expect(listCalls).toBe(0); + }); +}); diff --git a/fluxer_api/src/api/webhook/tests/WebhookMessageThreadMask.test.ts b/fluxer_api/src/api/webhook/tests/WebhookMessageThreadMask.test.ts new file mode 100644 index 000000000..bf80607a2 --- /dev/null +++ b/fluxer_api/src/api/webhook/tests/WebhookMessageThreadMask.test.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + createChannelID, + createGuildID, + createMessageID, + type WebhookID, + type WebhookToken, +} from '@app/api/BrandedTypes'; +import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; +import {createMessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService'; +import {syncChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; +import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import type {Message} from '@app/api/models/Message'; +import type {SweegoWebhookService} from '@app/api/webhook/SweegoWebhookService'; +import {WebhookRequestService} from '@app/api/webhook/WebhookRequestService'; +import type {WebhookService} from '@app/api/webhook/WebhookService'; +import {MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants'; +import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +const GUILD = createGuildID(900n); +const CHANNEL = createChannelID(100n); + +function setConfig(config: Record | null): void { + syncChannelThreadsConfig(config === null ? null : JSON.stringify(config), (raw) => + ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}), + ); +} + +async function getMessage(): Promise { + const message = {id: createMessageID(41n), channelId: CHANNEL} as unknown as Message; + const response = { + id: '41', + channel_id: '100', + type: MessageTypes.DEFAULT, + flags: ServerMessageFlags.HAS_THREAD | 4, + } as MessageResponse; + vi.spyOn(createMessageResponseDataService(), 'buildMessage').mockResolvedValue(response); + const service = new WebhookRequestService( + {getWebhookMessage: async () => message} as unknown as WebhookService, + {findUnique: async () => ({id: CHANNEL, guildId: GUILD})} as unknown as IChannelRepository, + {} as UserCacheService, + null, + {} as SweegoWebhookService, + {} as IGatewayService, + ); + return service.getWebhookMessage({ + webhookId: 1n as WebhookID, + token: 'token' as WebhookToken, + messageId: message.id, + requestCache: {} as RequestCache, + }); +} + +describe('webhook message thread masking', () => { + afterEach(() => { + setConfig(null); + vi.restoreAllMocks(); + }); + + it('masks the thread flag once the guild is killed', async () => { + setConfig({enabled: false, ever_enabled: true}); + expect((await getMessage()).flags).toBe(4); + }); + + it('keeps the thread flag while the guild is active', async () => { + setConfig({enabled: true, ever_enabled: true, enabled_guild_ids: ['900']}); + expect((await getMessage()).flags).toBe(ServerMessageFlags.HAS_THREAD | 4); + }); +}); diff --git a/fluxer_api/src/api/worker/CronScheduler.ts b/fluxer_api/src/api/worker/CronScheduler.ts index 587277b94..347e84fa1 100644 --- a/fluxer_api/src/api/worker/CronScheduler.ts +++ b/fluxer_api/src/api/worker/CronScheduler.ts @@ -28,6 +28,7 @@ interface CronDefinition { payload: WorkerJobPayload; schedule: CronSchedule; ledger: boolean; + enabled: (() => boolean) | null; lastFired: number; } @@ -127,7 +128,7 @@ export class CronScheduler { taskType: WorkerTaskName, payload: WorkerJobPayload, cronExpression: string, - options: {ledger: boolean}, + options: {ledger: boolean; enabled?: () => boolean}, ): void { this.definitions.set(id, { id, @@ -135,6 +136,7 @@ export class CronScheduler { payload, schedule: compileCronSchedule(cronExpression), ledger: options.ledger, + enabled: options.enabled ?? null, lastFired: 0, }); } @@ -197,6 +199,9 @@ export class CronScheduler { } def.lastFired = dueSecond; try { + if (def.enabled !== null && !def.enabled()) { + continue; + } const jobKey = `cron:${def.id}:${dueSecond}`; const acquired = await this.acquireEnqueueLease(jobKey); if (!acquired) { diff --git a/fluxer_api/src/api/worker/WorkerLaneConfig.ts b/fluxer_api/src/api/worker/WorkerLaneConfig.ts index 83b323621..d2079f250 100644 --- a/fluxer_api/src/api/worker/WorkerLaneConfig.ts +++ b/fluxer_api/src/api/worker/WorkerLaneConfig.ts @@ -85,6 +85,14 @@ const LANE_CONFIG = { 'pollAppStoreNotificationHistory', 'prunePostgresKvTtl', 'refreshSearchIndex', + 'seedThreadPermissions', + 'archiveInactiveThreads', + 'rebuildThreadAutoArchiveQueue', + 'repairThreadIndexes', + 'deleteChannelThreads', + 'removeThreadMembershipsForGuildMember', + 'syncThreadSearchDocument', + 'backfillThreadSearch', 'syncDiscoveryIndex', 'syncUrlBlocklists', 'syncFileShaBlocklists', diff --git a/fluxer_api/src/api/worker/WorkerMain.ts b/fluxer_api/src/api/worker/WorkerMain.ts index b4ee997d2..764ed1143 100644 --- a/fluxer_api/src/api/worker/WorkerMain.ts +++ b/fluxer_api/src/api/worker/WorkerMain.ts @@ -4,6 +4,7 @@ import {Config} from '@app/api/Config'; import {createApiContext} from '@app/api/CreateApiContext'; import {setDatabaseQueryExecutor} from '@app/api/database/CassandraQueryExecution'; import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor'; +import {channelThreadsEnabled, everEnabled} from '@app/api/experiment/ChannelThreadsGate'; import { jetStreamActivityPublisher, shutdownActivityEvents, @@ -51,13 +52,22 @@ import {createWorkerProcessErrorHandler} from '@app/api/worker/WorkerProcessErro import {WorkerRunner} from '@app/api/worker/WorkerRunner'; import {WorkerService} from '@app/api/worker/WorkerService'; import {workerTasks} from '@app/api/worker/WorkerTaskRegistry'; -import {setupGracefulShutdown} from '@fluxer/hono/src/Server'; +import {createRegisteredMetricsHandler} from '@fluxer/hono/src/middleware/Metrics'; +import {createServer, setupGracefulShutdown} from '@fluxer/hono/src/Server'; +import type {ServerType} from '@hono/node-server'; import {BACKGROUND_READ_TIMEOUT_MS, initCassandra, shutdownCassandra} from '@pkgs/cassandra/src/Client'; import {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager'; import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client'; import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {Hono} from 'hono'; import {ms} from 'itty-time'; +function startWorkerMetricsServer(port: number): ServerType { + const app = new Hono(); + app.get('/_metrics', createRegisteredMetricsHandler()); + return createServer(app, {port, onListen: (info) => Logger.info({port: info.port}, 'Worker metrics listening')}); +} + function registerCronJobs(cron: CronScheduler, jobsStreamMaxAgeMs: number): void { cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *', {ledger: false}); if (Config.cachePurge.adapter !== 'none') { @@ -98,6 +108,10 @@ function registerCronJobs(cron: CronScheduler, jobsStreamMaxAgeMs: number): void } cron.upsert('flushUserActivityBuffer', 'flushUserActivityBuffer', {}, '*/10 * * * * *', {ledger: false}); cron.upsert('drainActivitySpool', 'drainActivitySpool', {}, '*/5 * * * * *', {ledger: false}); + cron.upsert('archiveInactiveThreads', 'archiveInactiveThreads', {}, '0 * * * * *', { + ledger: false, + enabled: () => everEnabled() || channelThreadsEnabled(), + }); Logger.info( { blocklistFeeds: Config.blocklistFeeds.enabled, @@ -118,6 +132,7 @@ export async function startWorkerMain(): Promise { let instanceConfigRepository: InstanceConfigRepository | null = null; let dependencies: WorkerDependencies | null = null; let cron: CronScheduler | null = null; + let metricsServer: ServerType | null = null; const heartbeat = new WorkerHeartbeat({logger: Logger}); const runners: Array = []; let searchInitialized = false; @@ -135,6 +150,10 @@ export async function startWorkerMain(): Promise { Logger.info('Shutting down worker backend...'); const voiceShutdown = cleanupStep('voice resources', shutdownVoiceResources); await cleanupStep('heartbeat', () => heartbeat.stop()); + await cleanupStep('metrics server', () => { + metricsServer?.close(); + metricsServer = null; + }); await cleanupStep('cron', () => cron?.stop()); await cleanupStep('account actions', stopAccountActionConsumer); await cleanupStep('runners', async () => { @@ -340,6 +359,9 @@ export async function startWorkerMain(): Promise { 'Worker runners started', ); heartbeat.start(); + if (Config.worker.metricsPort !== undefined) { + metricsServer = startWorkerMetricsServer(Config.worker.metricsPort); + } setupGracefulShutdown(shutdown, {logger: Logger, timeoutMs: 30000}); const handleProcessError = createWorkerProcessErrorHandler({ logger: Logger, diff --git a/fluxer_api/src/api/worker/WorkerTaskRegistry.ts b/fluxer_api/src/api/worker/WorkerTaskRegistry.ts index 43d5ba634..30f80e6df 100644 --- a/fluxer_api/src/api/worker/WorkerTaskRegistry.ts +++ b/fluxer_api/src/api/worker/WorkerTaskRegistry.ts @@ -44,6 +44,7 @@ import reconcileUserPayments from '@app/api/worker/tasks/ReconcileUserPayments'; import refreshSearchIndex from '@app/api/worker/tasks/RefreshSearchIndex'; import refreshStorePurchase from '@app/api/worker/tasks/RefreshStorePurchase'; import removeChannelFollowers from '@app/api/worker/tasks/RemoveChannelFollowers'; +import seedThreadPermissions from '@app/api/worker/tasks/SeedThreadPermissions'; import {sendSystemDm} from '@app/api/worker/tasks/SendSystemDm'; import syncCrosspostCopies from '@app/api/worker/tasks/SyncCrosspostCopies'; import syncCrosspostedMessage from '@app/api/worker/tasks/SyncCrosspostedMessage'; @@ -51,6 +52,14 @@ import syncDiscoveryIndex from '@app/api/worker/tasks/SyncDiscoveryIndex'; import syncFileShaBlocklists from '@app/api/worker/tasks/SyncFileShaBlocklists'; import syncStripeCustomerEmail from '@app/api/worker/tasks/SyncStripeCustomerEmail'; import syncUrlBlocklists from '@app/api/worker/tasks/SyncUrlBlocklists'; +import { + archiveInactiveThreads, + deleteChannelThreads, + rebuildThreadAutoArchiveQueue, + removeThreadMembershipsForGuildMember, + repairThreadIndexes, +} from '@app/api/worker/tasks/ThreadMaintenanceTasks'; +import {backfillThreadSearch, syncThreadSearchDocument} from '@app/api/worker/tasks/ThreadSearchTasks'; import userProcessPendingDeletion from '@app/api/worker/tasks/UserProcessPendingDeletion'; import userProcessPendingDeletions from '@app/api/worker/tasks/UserProcessPendingDeletions'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; @@ -58,6 +67,13 @@ import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; export const workerTasks: Record = { applicationProcessDeletion, + archiveInactiveThreads, + deleteChannelThreads, + rebuildThreadAutoArchiveQueue, + removeThreadMembershipsForGuildMember, + repairThreadIndexes, + syncThreadSearchDocument, + backfillThreadSearch, batchGuildAuditLogMessageDeletes, bulkAddGuildMembers: bulkAddGuildMembers, bulkBanFileShas: bulkBanFileShas, @@ -99,6 +115,7 @@ export const workerTasks: Record = { pollAppStoreNotificationHistory, prunePostgresKvTtl, refreshSearchIndex, + seedThreadPermissions, removeChannelFollowers, sendSystemDm, syncStripeCustomerEmail, diff --git a/fluxer_api/src/api/worker/tasks/ExtractEmbeds.ts b/fluxer_api/src/api/worker/tasks/ExtractEmbeds.ts index b52d2c1a9..ad40894b7 100644 --- a/fluxer_api/src/api/worker/tasks/ExtractEmbeds.ts +++ b/fluxer_api/src/api/worker/tasks/ExtractEmbeds.ts @@ -9,6 +9,7 @@ import { isCrosspostedMessage, } from '@app/api/channel/services/message/CrosspostPropagation'; import {buildBroadcastMessageData} from '@app/api/channel/services/message/MessageGatewayDispatch'; +import {decrementThreadMessageCount} from '@app/api/channel/services/message/MessageHelpers'; import type {MessageEmbed, MessageEmbedChild} from '@app/api/database/types/MessageTypes'; import type {ModerationContext} from '@app/api/infrastructure/ContentModerationService'; import {contentModerationService} from '@app/api/infrastructure/ContentModerationService'; @@ -499,6 +500,7 @@ const extractEmbeds: WorkerTaskHandler = async (payload, helpers) => { latestExpectedMessage.authorId || createUserID(0n), latestExpectedMessage.pinnedTimestamp || undefined, ); + await decrementThreadMessageCount(channelRepository, channel, [messageId]); await deleteMessageSearchDocuments([messageId], {context: {source: 'blocked_embed_unfurl'}}); const eventDispatcher = new ChannelEventDispatcher({gatewayService}); await eventDispatcher.dispatchMessageDelete(channel, messageId); diff --git a/fluxer_api/src/api/worker/tasks/HandleMentionChunk.ts b/fluxer_api/src/api/worker/tasks/HandleMentionChunk.ts index c2bb6d4d9..a950c5b76 100644 --- a/fluxer_api/src/api/worker/tasks/HandleMentionChunk.ts +++ b/fluxer_api/src/api/worker/tasks/HandleMentionChunk.ts @@ -1,13 +1,28 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {createChannelID, createGuildID, createMessageID, createUserID, type UserID} from '@app/api/BrandedTypes'; +import { + createChannelID, + createGuildID, + createMessageID, + createUserID, + type GuildID, + type UserID, +} from '@app/api/BrandedTypes'; +import {guildActive} from '@app/api/experiment/ChannelThreadsGate'; import {Logger} from '@app/api/Logger'; +import type {ReadStateMarker} from '@app/api/read_state/IReadStateRepository'; import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils'; import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import {ReadStateFlags} from '@fluxer/constants/src/ThreadConstants'; import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; import {z} from 'zod'; const MENTION_SETTINGS_FETCH_CONCURRENCY = 16; +const THREAD_READ_STATE_FLAGS = ReadStateFlags.IS_GUILD_CHANNEL | ReadStateFlags.IS_THREAD; + +function threadReadStateMarker(guildId: GuildID): ReadStateMarker | null { + return guildActive(guildId) ? {flags: THREAD_READ_STATE_FLAGS, guildId} : null; +} const MentionChunkEntrySchema = z.object({ userId: z.string(), direct: z.boolean().optional(), @@ -20,6 +35,7 @@ const PayloadSchema = z.object({ guildId: z.string().optional(), chunkIndex: z.number().int().nonnegative().optional(), chunkCount: z.number().int().positive().optional(), + thread: z.boolean().optional(), mentions: z.array(MentionChunkEntrySchema), }); @@ -57,6 +73,11 @@ const handleMentionChunk: WorkerTaskHandler = async (payload, helpers) => { const channelId = createChannelID(BigInt(validated.channelId)); const messageId = createMessageID(BigInt(validated.messageId)); const guildId = validated.guildId ? createGuildID(BigInt(validated.guildId)) : null; + const marker = validated.thread && guildId !== null ? threadReadStateMarker(guildId) : null; + if (validated.thread && marker === null) { + Logger.debug({channelId, messageId}, 'Thread mention chunk outside the experiment, skipping'); + return; + } const mentions = mergeMentionEntries(validated.mentions); if (mentions.length === 0) { Logger.debug({channelId, messageId}, 'Mention chunk empty, skipping'); @@ -114,7 +135,7 @@ const handleMentionChunk: WorkerTaskHandler = async (payload, helpers) => { return; } await readStateService.bulkIncrementMentionCounts( - countedMentions.map((mention) => ({userId: mention.userId, channelId, messageId})), + countedMentions.map((mention) => ({userId: mention.userId, channelId, messageId, marker})), ); if (guildId != null) { await userRepository.createRecentMentions( diff --git a/fluxer_api/src/api/worker/tasks/HandleMentions.ts b/fluxer_api/src/api/worker/tasks/HandleMentions.ts index b0dd9b1b2..295eec483 100644 --- a/fluxer_api/src/api/worker/tasks/HandleMentions.ts +++ b/fluxer_api/src/api/worker/tasks/HandleMentions.ts @@ -15,6 +15,7 @@ import { } from '@app/api/BrandedTypes'; import type {GatewayMentionSourceEntry, IGatewayService} from '@app/api/infrastructure/IGatewayService'; import {Logger} from '@app/api/Logger'; +import {ThreadMentionScope} from '@app/api/worker/tasks/ThreadMentionScope'; import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; import type {WorkerTaskHandler, WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; import {z} from 'zod'; @@ -118,6 +119,7 @@ async function enqueueMentionChunks({ addJob, firstChunkIndex = 0, chunkCount, + thread = false, }: { chunks: Array>; channelId: string; @@ -126,6 +128,7 @@ async function enqueueMentionChunks({ addJob: WorkerTaskHelpers['addJob']; firstChunkIndex?: number; chunkCount?: number; + thread?: boolean; }): Promise { for (let offset = 0; offset < chunks.length; offset += MENTION_CHUNK_ENQUEUE_CONCURRENCY) { const chunkSlice = chunks.slice(offset, offset + MENTION_CHUNK_ENQUEUE_CONCURRENCY); @@ -140,6 +143,7 @@ async function enqueueMentionChunks({ guildId, chunkIndex: index, ...(chunkCount !== undefined && {chunkCount}), + ...(thread && {thread: true}), mentions: chunk, }, { @@ -164,6 +168,7 @@ async function enqueueGuildMentionSourcePages({ roleIds, userIds, addJob, + threadScope, }: { gatewayService: IGatewayService; guildId: GuildID; @@ -175,6 +180,7 @@ async function enqueueGuildMentionSourcePages({ roleIds: Array; userIds: Array; addJob: WorkerTaskHelpers['addJob']; + threadScope: ThreadMentionScope | null; }): Promise<{totalMentioned: number; chunkCount: number; pageCount: number}> { let cursor: string | undefined; let chunkIndex = 0; @@ -193,8 +199,9 @@ async function enqueueGuildMentionSourcePages({ ...(cursor !== undefined && {cursor}), }); pageCount++; - totalMentioned += page.mentions.length; - const chunks = chunkEntries(page.mentions.map(toMentionChunkEntry), MENTION_CHUNK_SIZE); + const mentions = threadScope ? await threadScope.filter(page.mentions) : page.mentions; + totalMentioned += mentions.length; + const chunks = chunkEntries(mentions.map(toMentionChunkEntry), MENTION_CHUNK_SIZE); chunkIndex = await enqueueMentionChunks({ chunks, channelId: channelId.toString(), @@ -202,6 +209,7 @@ async function enqueueGuildMentionSourcePages({ guildId: guildId.toString(), addJob, firstChunkIndex: chunkIndex, + thread: threadScope !== null, }); const nextCursor = page.nextCursor ?? undefined; if (nextCursor !== undefined && nextCursor === cursor) { @@ -219,7 +227,7 @@ async function enqueueGuildMentionSourcePages({ const handleMentions: WorkerTaskHandler = async (payload, helpers) => { const validated = PayloadSchema.parse(payload); helpers.logger.debug({payload: validated}, 'Processing handleMentions task'); - const {channelRepository, gatewayService} = getWorkerDependencies(); + const {channelRepository, gatewayService, userRepository} = getWorkerDependencies(); const authorId = createUserID(BigInt(validated.authorId)); const channelId = createChannelID(BigInt(validated.channelId)); const messageId = createMessageID(BigInt(validated.messageId)); @@ -241,6 +249,11 @@ const handleMentions: WorkerTaskHandler = async (payload, helpers) => { const userIds = validated.mentionUserIds?.map((userId) => createUserID(BigInt(userId))) ?? Array.from(message.mentionedUserIds); if (channel.guildId) { + const threadScope = await ThreadMentionScope.load( + {channelRepository, gatewayService, userRepository}, + channel, + messageId, + ); const result = await enqueueGuildMentionSourcePages({ gatewayService, guildId: channel.guildId, @@ -252,7 +265,9 @@ const handleMentions: WorkerTaskHandler = async (payload, helpers) => { userIds, messageId, addJob: helpers.addJob, + threadScope, }); + await threadScope?.finish(); if (result.totalMentioned === 0) { Logger.debug({channelId, guildId: channel.guildId}, 'No users to mention, skipping read state updates'); return; diff --git a/fluxer_api/src/api/worker/tasks/HarvestGuildData.ts b/fluxer_api/src/api/worker/tasks/HarvestGuildData.ts index 7f7e73fb6..a9db0175f 100644 --- a/fluxer_api/src/api/worker/tasks/HarvestGuildData.ts +++ b/fluxer_api/src/api/worker/tasks/HarvestGuildData.ts @@ -28,6 +28,7 @@ import { } from '@app/api/worker/utils/AssetArchiveHelpers'; import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; import {GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants'; +import {MAX_THREAD_MEMBERS, THREAD_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; import {z} from 'zod'; @@ -135,11 +136,24 @@ const harvestGuildData: ArchiveTaskHandler = async (payload, helpers, attempt) = const [roles, members, channels, emojis, stickers] = await Promise.all([ guildRepository.listRoles(guildId), guildRepository.listMembers(guildId), - channelRepository.channelData.listGuildChannels(guildId), + channelRepository.channelData + .listGuildChannels(guildId, 'complete') + .then(async (guildChannels) => [ + ...guildChannels, + ...(await channelRepository.channelData.listChannels( + await channelRepository.threads.listGuildThreadIds(guildId, {parents: guildChannels}), + )), + ]), guildRepository.listEmojis(guildId), guildRepository.listStickers(guildId), ]); await updateProgress(P_META, 'Writing guild metadata'); + const threadStates = await channelRepository.threads.getStates( + channels.filter((c) => THREAD_CHANNEL_TYPES.has(c.type)).map((c) => c.id), + ); + const threadMembers = await mapWithConcurrency(threadStates, CHANNEL_CONCURRENCY, (state) => + channelRepository.threads.listMembers(state.threadId, {limit: MAX_THREAD_MEMBERS}), + ); const guildJson = { guild: { id: guild.id.toString(), @@ -192,10 +206,28 @@ const harvestGuildData: ArchiveTaskHandler = async (payload, helpers, attempt) = position: c.position, last_message_id: c.lastMessageId?.toString() ?? null, })), + ...(threadStates.length > 0 + ? { + threads: threadStates.map((state, index) => ({ + id: state.threadId.toString(), + parent_id: state.parentId.toString(), + type: state.type, + archived: state.archived, + locked: state.locked, + invitable: state.invitable, + auto_archive_duration: state.autoArchiveDuration, + archive_timestamp: state.archiveTimestamp?.toISOString() ?? null, + created_at: state.createdAt.toISOString(), + member_ids: threadMembers[index]!.map((member) => member.userId.toString()), + })), + } + : {}), }; await fs.promises.writeFile(path.join(contentDir, 'guild.json'), createArchiveJsonBuffer(guildJson)); await updateProgress(P_META, `Harvesting messages from ${channels.length} channels`); - const textChannels = channels.filter((c) => GUILD_TEXT_BASED_CHANNEL_TYPES.has(c.type)); + const textChannels = channels.filter( + (c) => GUILD_TEXT_BASED_CHANNEL_TYPES.has(c.type) || THREAD_CHANNEL_TYPES.has(c.type), + ); const pendingDownloads: Array = []; let processedChannels = 0; await mapWithConcurrency(textChannels, CHANNEL_CONCURRENCY, async (channel) => { diff --git a/fluxer_api/src/api/worker/tasks/HarvestUserData.test.ts b/fluxer_api/src/api/worker/tasks/HarvestUserData.test.ts index ba3fba0ed..6eb7aa3c4 100644 --- a/fluxer_api/src/api/worker/tasks/HarvestUserData.test.ts +++ b/fluxer_api/src/api/worker/tasks/HarvestUserData.test.ts @@ -1,12 +1,31 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {type ChannelID, createChannelID, createMessageID, createUserID, type MessageID} from '@app/api/BrandedTypes'; +import { + type ChannelID, + createChannelID, + createGuildID, + createMessageID, + createUserID, + type GuildID, + type MessageID, + type UserID, +} from '@app/api/BrandedTypes'; import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes'; +import {syncChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate'; +import {ThreadMember} from '@app/api/models/ThreadMember'; import {User} from '@app/api/models/User'; import {mapStorePurchaseToResponse} from '@app/api/store_billing/StoreBillingMappers'; import {buildStorePurchaseRow} from '@app/api/store_billing/tests/StoreBillingTestUtils'; -import {buildUserDataJson, harvestMessages} from '@app/api/worker/tasks/HarvestUserData'; -import {describe, expect, it} from 'vitest'; +import {buildUserDataJson, collectThreadMemberships, harvestMessages} from '@app/api/worker/tasks/HarvestUserData'; +import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +const tainted = vi.hoisted(() => new Set()); + +vi.mock('@app/api/experiment/ChannelThreadsGate', async (importOriginal) => ({ + ...(await importOriginal()), + isTainted: async (guildId: bigint) => tainted.has(guildId.toString()), +})); const AUTHOR = createUserID(1000000000000000000n); @@ -92,6 +111,7 @@ function userDataParams(overrides: Partial[ createdGiftCodes: [], payments: [], storePurchases: [], + threadMemberships: [], oauthClients: [], connections: [], pinnedDms: [], @@ -119,3 +139,50 @@ describe('buildUserDataJson', () => { expect('recovery_kit' in data).toBe(false); }); }); + +describe('collectThreadMemberships', () => { + const guildId = createGuildID(1400000000000000000n); + const threadId = createChannelID(1400000000000000010n); + const member = new ThreadMember({ + thread_id: threadId, + user_id: AUTHOR, + guild_id: guildId, + parent_id: createChannelID(1400000000000000001n), + join_timestamp: new Date('2026-09-27T00:00:00.000Z'), + flags: 1, + muted: false, + mute_config: null, + }); + let reads = 0; + const threads = { + listJoinedThreadIds: async (_userId: UserID, id: GuildID) => { + reads++; + return id === guildId ? [threadId] : []; + }, + getMember: async () => member, + }; + const setEverEnabled = (everEnabled: boolean) => + syncChannelThreadsConfig(JSON.stringify({ever_enabled: everEnabled}), (raw) => + ChannelThreadsConfigSchema.parse(JSON.parse(raw ?? '{}')), + ); + + afterEach(() => { + reads = 0; + tainted.clear(); + syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({})); + }); + + it('reads nothing on an instance that never enabled threads', async () => { + setEverEnabled(false); + expect(await collectThreadMemberships(threads, AUTHOR, [guildId])).toEqual([]); + expect(reads).toBe(0); + }); + + it('collects the joined thread memberships of every tainted guild and skips the rest', async () => { + setEverEnabled(true); + tainted.add(guildId.toString()); + const collected = await collectThreadMemberships(threads, AUTHOR, [guildId, createGuildID(1400000000000000002n)]); + expect(collected).toEqual([member]); + expect(reads).toBe(1); + }); +}); diff --git a/fluxer_api/src/api/worker/tasks/HarvestUserData.ts b/fluxer_api/src/api/worker/tasks/HarvestUserData.ts index 16e719d1b..9e1f3129d 100644 --- a/fluxer_api/src/api/worker/tasks/HarvestUserData.ts +++ b/fluxer_api/src/api/worker/tasks/HarvestUserData.ts @@ -23,6 +23,7 @@ import { type UserID, } from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; +import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository'; import { isChannelEligible, isTimestampInWindow, @@ -31,6 +32,7 @@ import { } from '@app/api/channel/services/message/SelfMessageFilter'; import type {UserConnectionRow} from '@app/api/database/types/ConnectionTypes'; import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import {everEnabled, isTainted} from '@app/api/experiment/ChannelThreadsGate'; import type {IStorageService} from '@app/api/infrastructure/IStorageService'; import {Logger} from '@app/api/Logger'; import type {Application} from '@app/api/models/Application'; @@ -46,6 +48,7 @@ import type {Payment} from '@app/api/models/Payment'; import type {PushSubscription} from '@app/api/models/PushSubscription'; import type {Relationship} from '@app/api/models/Relationship'; import type {SavedMessage} from '@app/api/models/SavedMessage'; +import type {ThreadMember} from '@app/api/models/ThreadMember'; import type {User} from '@app/api/models/User'; import type {UserGuildSettings} from '@app/api/models/UserGuildSettings'; import type {UserSettings} from '@app/api/models/UserSettings'; @@ -127,6 +130,7 @@ interface UserDataJsonParams { userSettings: UserSettings | null; guildMemberships: Array; guildSettings: Array; + threadMemberships: Array; savedMessages: Array; privateChannels: Array; favoriteMemes: Array; @@ -412,6 +416,21 @@ export async function harvestMessages( return {channelMessagesMap, totalMessages}; } +export async function collectThreadMemberships( + threads: Pick, + userId: UserID, + guildIds: ReadonlyArray, +): Promise> { + if (!everEnabled()) return []; + const perGuild = await mapWithConcurrency(guildIds, HARVEST_READ_CONCURRENCY, async (guildId) => { + if (!(await isTainted(guildId, {fresh: true}))) return []; + const threadIds = await threads.listJoinedThreadIds(userId, guildId); + const members = await Promise.all(threadIds.map((threadId) => threads.getMember(threadId, userId))); + return members.filter((member): member is ThreadMember => member !== null); + }); + return perGuild.flat(); +} + export function buildUserDataJson(params: UserDataJsonParams) { const { user, @@ -423,6 +442,7 @@ export function buildUserDataJson(params: UserDataJsonParams) { userSettings, guildMemberships, guildSettings, + threadMemberships, savedMessages, privateChannels, favoriteMemes, @@ -579,6 +599,18 @@ export function buildUserDataJson(params: UserDataJsonParams) { suppress_roles: settings!.suppressRoles, hide_muted_channels: settings!.hideMutedChannels, })), + ...(threadMemberships.length > 0 + ? { + thread_memberships: threadMemberships.map((member) => ({ + thread_id: member.threadId.toString(), + guild_id: member.guildId.toString(), + parent_id: member.parentId.toString(), + join_timestamp: member.joinTimestamp.toISOString(), + flags: member.flags, + muted: member.muted, + })), + } + : {}), saved_messages: savedMessages.map((msg) => ({ channel_id: msg.channelId.toString(), message_id: msg.messageId.toString(), @@ -894,6 +926,7 @@ const harvestUserData: ArchiveTaskHandler = async (payload, helpers, attempt) => const guildSettings = await mapWithConcurrency(guildIds, HARVEST_READ_CONCURRENCY, (guildId) => userRepository.findGuildSettings(userId, guildId), ); + const threadMemberships = await collectThreadMemberships(channelRepository.threads, userId, guildIds); const {branding} = await instanceConfigRepository.getAppPublicConfig(); const userData = buildUserDataJson({ user, @@ -905,6 +938,7 @@ const harvestUserData: ArchiveTaskHandler = async (payload, helpers, attempt) => userSettings, guildMemberships, guildSettings, + threadMemberships, savedMessages, privateChannels, favoriteMemes, diff --git a/fluxer_api/src/api/worker/tasks/IndexChannelMessages.ts b/fluxer_api/src/api/worker/tasks/IndexChannelMessages.ts index a56329bab..c9c4c36c6 100644 --- a/fluxer_api/src/api/worker/tasks/IndexChannelMessages.ts +++ b/fluxer_api/src/api/worker/tasks/IndexChannelMessages.ts @@ -2,6 +2,7 @@ import type {MessageID, UserID} from '@app/api/BrandedTypes'; import {createChannelID} from '@app/api/BrandedTypes'; +import {isMessageSearchIndexable} from '@app/api/channel/services/message/MessageSearchService'; import {Logger} from '@app/api/Logger'; import {getMessageSearchService} from '@app/api/SearchFactory'; import type {IMessageSearchService} from '@app/api/search/IMessageSearchService'; @@ -54,7 +55,8 @@ const indexChannelMessages: WorkerTaskHandler = async (payload) => { authorBotCache.set(user.id, user.isBot); } } - await Promise.all(searchServices.map((s) => s.bulkIndexMessages(messages, authorBotCache))); + const indexable = messages.filter(isMessageSearchIndexable); + await Promise.all(searchServices.map((s) => s.bulkIndexMessages(indexable, authorBotCache))); totalIndexed += messages.length; cursor = messages[messages.length - 1]!.id; Logger.debug( @@ -80,7 +82,9 @@ const indexChannelMessages: WorkerTaskHandler = async (payload) => { } } const channel = await channelRepository.findUnique(channelId); - if (channel) { + if (channel?.isThread()) { + await channelRepository.channelData.patchIndexedAt(channelId, new Date()); + } else if (channel) { await channelRepository.upsert({...channel.toRow(), indexed_at: new Date()}); } Logger.info({channelId: channelId.toString(), totalIndexed}, 'Bulk channel indexing complete'); diff --git a/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts b/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts index 41d7fa677..835762197 100644 --- a/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts +++ b/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts @@ -2,6 +2,7 @@ import type {GuildID, ReportID, UserID} from '@app/api/BrandedTypes'; import {createGuildID} from '@app/api/BrandedTypes'; +import {channelThreadsEnabled, guildActive} from '@app/api/experiment/ChannelThreadsGate'; import {GuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository'; import {Logger} from '@app/api/Logger'; import type {User} from '@app/api/models/User'; @@ -11,11 +12,13 @@ import { getGuildSearchService, getMessageSearchService, getReportSearchService, + getThreadSearchService, getUserSearchService, } from '@app/api/SearchFactory'; import type {IGuildMemberSearchService} from '@app/api/search/IGuildMemberSearchService'; import type {IMessageSearchService} from '@app/api/search/IMessageSearchService'; import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup'; +import {backfillThreadSearch} from '@app/api/search/thread/ThreadSearchService'; import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; @@ -32,6 +35,7 @@ const INDEX_TYPES = [ 'channel_messages', 'guild_members', 'discovery', + 'threads', ] as const; type IndexType = (typeof INDEX_TYPES)[number]; @@ -46,7 +50,11 @@ const PayloadSchema = z }) .refine( (data) => { - if (data.index_type === 'channel_messages' || data.index_type === 'guild_members') { + if ( + data.index_type === 'channel_messages' || + data.index_type === 'guild_members' || + data.index_type === 'threads' + ) { return data.guild_id !== undefined; } return true; @@ -197,11 +205,14 @@ const refreshChannelMessages: IndexHandler = async (payload, helpers, kvClient, const {channelRepository} = getWorkerDependencies(); const guildId = createGuildID(BigInt(payload.guild_id!)); const searchService = requireSearchService(getMessageSearchService()); - const channels = await channelRepository.listGuildChannels(guildId); + const guildChannels = await channelRepository.listGuildChannels(guildId, 'complete'); + const threadIds = await channelRepository.threads.listGuildThreadIds(guildId, {parents: guildChannels}); + const channels = [...guildChannels.map((channel) => ({id: channel.id})), ...threadIds.map((id) => ({id}))]; if (channels.length === 0) { return 0; } - for (const channel of channels) { + const indexedChannels = guildActive(guildId) ? channels : guildChannels; + for (const channel of indexedChannels) { await deleteChannelMessageSearchDocuments(channel.id, { searchService, context: {source: 'bulk_reindex', guildId: guildId.toString()}, @@ -209,14 +220,14 @@ const refreshChannelMessages: IndexHandler = async (payload, helpers, kvClient, } const completionKey = `bulk_reindex:${payload.job_id}:remaining`; await kvClient.del(completionKey); - for (const channel of channels) { + for (const channel of indexedChannels) { Logger.debug({channelId: channel.id.toString()}, 'Queuing bulk channel indexing'); await helpers.addJob( 'indexChannelMessages', { channelId: channel.id.toString(), completionKey, - channelCount: channels.length, + channelCount: indexedChannels.length, }, { jobKey: `index-channel-${channel.id}-bulk`, @@ -224,8 +235,11 @@ const refreshChannelMessages: IndexHandler = async (payload, helpers, kvClient, }, ); } - Logger.info({channels: channels.length, guildId: guildId.toString()}, 'Queued bulk channel message indexing jobs'); - return channels.length; + Logger.info( + {channels: indexedChannels.length, guildId: guildId.toString()}, + 'Queued bulk channel message indexing jobs', + ); + return indexedChannels.length; }; const refreshGuildMembers: IndexHandler = async (payload, _helpers, kvClient, progressKey) => { const {guildRepository, userRepository} = getWorkerDependencies(); @@ -305,6 +319,14 @@ const refreshDiscovery: IndexHandler = async (_payload, _helpers, kvClient, prog } return synced; }; +const refreshThreads: IndexHandler = async (payload) => { + if (!channelThreadsEnabled()) return 0; + const {channelRepository} = getWorkerDependencies(); + const guildId = createGuildID(BigInt(payload.guild_id!)); + requireSearchService(getThreadSearchService()); + await channelRepository.threads.clearGuildSearchBackfilled(guildId); + return backfillThreadSearch(channelRepository, guildId); +}; const INDEX_HANDLERS: Record = { guilds: refreshGuilds, users: refreshUsers, @@ -313,6 +335,7 @@ const INDEX_HANDLERS: Record = { channel_messages: refreshChannelMessages, guild_members: refreshGuildMembers, discovery: refreshDiscovery, + threads: refreshThreads, }; const refreshSearchIndex: WorkerTaskHandler = async (payload, helpers) => { const validated = PayloadSchema.parse(payload); diff --git a/fluxer_api/src/api/worker/tasks/SeedThreadPermissions.ts b/fluxer_api/src/api/worker/tasks/SeedThreadPermissions.ts new file mode 100644 index 000000000..9849a6b4a --- /dev/null +++ b/fluxer_api/src/api/worker/tasks/SeedThreadPermissions.ts @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createGuildID, type GuildID, guildIdToRoleId} from '@app/api/BrandedTypes'; +import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; +import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository'; +import {withThreadParentFieldsMany} from '@app/api/channel/services/thread/ThreadParentSettings'; +import {getCompiledChannelThreadsConfig, guildActive} from '@app/api/experiment/ChannelThreadsGate'; +import {mapGuildRoleToResponse} from '@app/api/guild/GuildModel'; +import {Logger} from '@app/api/Logger'; +import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware'; +import {getWorkerService} from '@app/api/middleware/ServiceRegistry'; +import type {Channel} from '@app/api/models/Channel'; +import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {DEFAULT_THREAD_PERMISSIONS, THREAD_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils'; +import { + type ChannelThreadsConfig, + channelThreadsGuildActive, + compileChannelThreadsConfig, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {z} from 'zod'; + +const PayloadSchema = z.object({ + guildId: z.string(), + configVersion: z.number().int().min(0).optional(), +}); + +const SEEDED_GUILD_CACHE_MAX_ENTRIES = 10_000; +const SEED_MARKER_READ_CHUNK = 100; +const seededGuildIds = new Set(); + +export function seedThreadOverwriteBits(value: bigint): bigint { + return value & ~THREAD_PERMISSIONS; +} + +export function seedThreadOverwriteValue(value: bigint): bigint { + const cleared = seedThreadOverwriteBits(value); + return (value & Permissions.SEND_MESSAGES) !== 0n ? cleared | DEFAULT_THREAD_PERMISSIONS : cleared; +} + +export function enabledThreadGuildIds(config: ChannelThreadsConfig): Array { + const compiled = compileChannelThreadsConfig(config); + return config.enabled_guild_ids.filter((id) => channelThreadsGuildActive(compiled, id)); +} + +export function newlyEnabledThreadGuildIds( + previous: ChannelThreadsConfig, + landed: ChannelThreadsConfig, +): Array { + const before = new Set(enabledThreadGuildIds(previous)); + return enabledThreadGuildIds(landed).filter((id) => !before.has(id)); +} + +export async function enqueueThreadPermissionSeeds( + threads: IThreadRepository, + config: ChannelThreadsConfig, +): Promise { + const guildIds = enabledThreadGuildIds(config); + for (let index = 0; index < guildIds.length; index += SEED_MARKER_READ_CHUNK) { + const chunk = guildIds.slice(index, index + SEED_MARKER_READ_CHUNK); + const markers = await Promise.all(chunk.map((id) => threads.getGuildMarker(createGuildID(BigInt(id))))); + for (const [position, guildId] of chunk.entries()) { + if (!markers[position]?.perms_seeded_at) await enqueueThreadPermissionSeed(guildId); + } + } +} + +async function enqueueThreadPermissionSeed(guildId: string): Promise { + await getWorkerService().addJob( + 'seedThreadPermissions', + {guildId, configVersion: getCompiledChannelThreadsConfig().config.config_version}, + {jobKey: `seed-thread-permissions-${guildId}`}, + ); +} + +export async function ensureGuildThreadPermissionsSeeded(threads: IThreadRepository, guildId: GuildID): Promise { + const key = guildId.toString(); + if (seededGuildIds.has(key)) return; + const marker = await threads.getGuildMarker(guildId); + if (marker?.perms_seeded_at) { + if (seededGuildIds.size >= SEEDED_GUILD_CACHE_MAX_ENTRIES) seededGuildIds.clear(); + seededGuildIds.add(key); + return; + } + if (!marker) await threads.ensureGuildMarker(guildId); + await enqueueThreadPermissionSeed(key); +} + +export async function ensureChannelThreadsConfigVersion(requiredVersion: number | undefined): Promise { + if (requiredVersion === undefined || getCompiledChannelThreadsConfig().config.config_version >= requiredVersion) + return; + const refreshed = await getWorkerDependencies().instanceConfigRepository.refreshChannelThreadsConfig(); + if (refreshed.config.config_version < requiredVersion) { + throw new Error(`Channel threads config ${refreshed.config.config_version} is older than ${requiredVersion}`); + } +} + +const seedThreadPermissions: WorkerTaskHandler = async (payload) => { + const validated = PayloadSchema.parse(payload); + const guildId = createGuildID(BigInt(validated.guildId)); + const {channelRepository, guildRepository, gatewayService, userCacheService} = getWorkerDependencies(); + await ensureChannelThreadsConfigVersion(validated.configVersion); + if (!guildActive(guildId)) return; + const marker = await channelRepository.threads.getGuildMarker(guildId); + if (marker?.perms_seeded_at) return; + const guild = await guildRepository.findUnique(guildId); + if (!guild) return; + await channelRepository.threads.ensureGuildMarker(guildId); + const channels = await channelRepository.listGuildChannels(guildId, 'complete'); + const updatedChannels: Array = []; + for (const channel of channels) { + if (channel.permissionOverwrites.size === 0) continue; + let changed = false; + const overwrites = new Map( + Array.from(channel.permissionOverwrites.entries()).map(([targetId, overwrite]) => { + const allow = seedThreadOverwriteValue(overwrite.allow); + const deny = seedThreadOverwriteValue(overwrite.deny); + if (allow !== overwrite.allow || deny !== overwrite.deny) changed = true; + return [targetId, {type: overwrite.type, allow_: allow, deny_: deny}]; + }), + ); + if (!changed) continue; + updatedChannels.push( + await channelRepository.channelData.upsert( + {...channel.toRow(), permission_overwrites: overwrites}, + channel.toRow(), + ), + ); + } + const everyone = await guildRepository.getRole(guildIdToRoleId(guildId), guildId); + const everyoneSeeded = + everyone && (everyone.permissions & Permissions.SEND_MESSAGES) !== 0n + ? everyone.permissions | DEFAULT_THREAD_PERMISSIONS + : null; + const updatedRole = + everyone && everyoneSeeded !== null && everyoneSeeded !== everyone.permissions + ? await guildRepository.upsertRole({...everyone.toRow(), permissions: everyoneSeeded}) + : null; + if (updatedRole) { + await gatewayService.dispatchGuild({ + guildId, + event: 'GUILD_ROLE_UPDATE_BULK', + data: {roles: [mapGuildRoleToResponse(updatedRole)]}, + }); + } + if (updatedChannels.length > 0) { + const requestCache = createRequestCache(); + const channelResponses = await Promise.all( + updatedChannels.map((channel) => + mapChannelToResponse({channel, currentUserId: null, userCacheService, requestCache}), + ), + ); + await gatewayService.dispatchGuild({ + guildId, + event: 'CHANNEL_UPDATE_BULK', + data: { + channels: await withThreadParentFieldsMany( + channelRepository.threads, + guildId, + updatedChannels, + channelResponses, + ), + }, + }); + } + await channelRepository.threads.markGuildPermsSeeded(guildId, new Date()); + Logger.info( + {guildId: guildId.toString(), channels: updatedChannels.length, everyoneUpdated: updatedRole !== null}, + 'Seeded thread permissions', + ); +}; + +export default seedThreadPermissions; diff --git a/fluxer_api/src/api/worker/tasks/SendSystemDm.ts b/fluxer_api/src/api/worker/tasks/SendSystemDm.ts index 07fb1d644..9a7bdf376 100644 --- a/fluxer_api/src/api/worker/tasks/SendSystemDm.ts +++ b/fluxer_api/src/api/worker/tasks/SendSystemDm.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createUserID, type UserID} from '@app/api/BrandedTypes'; +import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate'; import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {User} from '@app/api/models/User'; import {UserChannelService} from '@app/api/user/services/UserChannelService'; @@ -90,6 +91,7 @@ export async function sendSystemDm(payload: unknown, helpers: WorkerTaskHelpers) }); await deps.channelService.messages.send.sendMessage({ user: systemUser, + viewer: SYSTEM_THREAD_VIEWER, channelId: channel.id, data: {content}, requestCache, diff --git a/fluxer_api/src/api/worker/tasks/ThreadMaintenanceTasks.ts b/fluxer_api/src/api/worker/tasks/ThreadMaintenanceTasks.ts new file mode 100644 index 000000000..39471d3d6 --- /dev/null +++ b/fluxer_api/src/api/worker/tasks/ThreadMaintenanceTasks.ts @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type ChannelID, createChannelID, createGuildID, createUserID, type GuildID} from '@app/api/BrandedTypes'; +import { + dispatchThreadEvents, + threadMembersUpdateEvent, + threadUpdateEvent, +} from '@app/api/channel/services/thread/ThreadDispatch'; +import {loadThreadViews} from '@app/api/channel/services/thread/ThreadViews'; +import {enqueueThreadSearchSync} from '@app/api/channel/threads/ThreadJobs'; +import { + setThreadArchiveSweepLag, + threadArchiveSweepBatch, + threadArchiveSweepSkippedInactiveTotal, + threadsArchivedTotal, +} from '@app/api/channel/threads/ThreadMetrics'; +import {everEnabled, guildActive, isTainted} from '@app/api/experiment/ChannelThreadsGate'; +import { + KVThreadAutoArchiveQueueService, + threadAutoArchiveDueAt, +} from '@app/api/infrastructure/KVThreadAutoArchiveQueueService'; +import {Logger} from '@app/api/Logger'; +import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup'; +import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService'; +import {ensureChannelThreadsConfigVersion} from '@app/api/worker/tasks/SeedThreadPermissions'; +import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import {MAX_THREAD_ARCHIVES_PER_SWEEP_TICK} from '@fluxer/constants/src/ThreadConstants'; +import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {z} from 'zod'; + +const SnowflakeString = z.string().regex(/^\d{1,20}$/); +const GuildPayload = z.object({guildId: SnowflakeString, configVersion: z.number().int().min(0).optional()}); +const RepairPayload = z.object({threadIds: z.array(SnowflakeString).min(1).max(1000)}); +const ParentPayload = z.object({guildId: SnowflakeString, parentId: SnowflakeString}); +const MemberPayload = z.object({guildId: SnowflakeString, userId: SnowflakeString}); + +const PARENT_PAGE_SIZE = 100; + +function archiveQueue(): KVThreadAutoArchiveQueueService { + const {kvClient, channelRepository} = getWorkerDependencies(); + return new KVThreadAutoArchiveQueueService(kvClient, channelRepository.threads, channelRepository.channelData); +} + +async function archiveDueThreads( + queue: KVThreadAutoArchiveQueueService, + guildId: GuildID, + nowMs: number, + budget: number, +): Promise<{archived: number; oldestDueMs: number | null}> { + const {channelRepository, gatewayService} = getWorkerDependencies(); + const dueIds = await queue.getDue(guildId, nowMs, budget); + if (dueIds.length === 0) return {archived: 0, oldestDueMs: null}; + const [states, channels] = await Promise.all([ + channelRepository.threads.getStates(dueIds), + channelRepository.channelData.listChannels(dueIds), + ]); + const stateById = new Map(states.map((state) => [state.threadId.toString(), state])); + const channelById = new Map(channels.map((channel) => [channel.id.toString(), channel])); + let archived = 0; + let oldestDueMs: number | null = null; + for (const threadId of dueIds) { + const state = stateById.get(threadId.toString()); + const channel = channelById.get(threadId.toString()); + if (!state || !channel || state.archived || state.isPinned) { + await queue.remove(guildId, threadId); + continue; + } + const dueAt = threadAutoArchiveDueAt(state, channel.lastMessageId); + if (dueAt > nowMs) { + await queue.schedule(state, channel.lastMessageId); + continue; + } + oldestDueMs = oldestDueMs === null ? dueAt : Math.min(oldestDueMs, dueAt); + const transition = await channelRepository.threads.updateState(threadId, (current) => + current.archived || current.isPinned ? null : {archived: true}, + ); + await queue.remove(guildId, threadId); + if (!transition || transition.previous.archived || !transition.state.archived) continue; + archived++; + threadsArchivedTotal.inc('reason="auto"'); + const [view] = await loadThreadViews(channelRepository, [transition.state]); + if (view) await dispatchThreadEvents(gatewayService, guildId, [threadUpdateEvent(view)]); + enqueueThreadSearchSync(threadId); + } + return {archived, oldestDueMs}; +} + +export const archiveInactiveThreads: WorkerTaskHandler = async () => { + if (!everEnabled()) return; + const queue = archiveQueue(); + const nowMs = Date.now(); + let budget = MAX_THREAD_ARCHIVES_PER_SWEEP_TICK; + let oldestDueMs: number | null = null; + for (const guildId of await queue.listGuilds()) { + if (budget <= 0) break; + if (!guildActive(guildId)) { + threadArchiveSweepSkippedInactiveTotal.inc(); + continue; + } + const result = await archiveDueThreads(queue, guildId, nowMs, budget); + budget -= result.archived; + if (result.oldestDueMs !== null) { + oldestDueMs = oldestDueMs === null ? result.oldestDueMs : Math.min(oldestDueMs, result.oldestDueMs); + } + } + const archived = MAX_THREAD_ARCHIVES_PER_SWEEP_TICK - budget; + threadArchiveSweepBatch.observe(archived); + setThreadArchiveSweepLag(oldestDueMs === null ? 0 : (nowMs - oldestDueMs) / 1000); + if (archived > 0) Logger.info({archived}, 'Archived inactive threads'); +}; + +export const rebuildThreadAutoArchiveQueue: WorkerTaskHandler = async (payload) => { + const validated = GuildPayload.parse(payload); + await ensureChannelThreadsConfigVersion(validated.configVersion); + const guildId = createGuildID(BigInt(validated.guildId)); + if (!guildActive(guildId)) return; + const scheduled = await archiveQueue().rebuildGuild(guildId); + Logger.info({guildId: guildId.toString(), scheduled}, 'Rebuilt thread auto-archive queue'); +}; + +export const repairThreadIndexes: WorkerTaskHandler = async (payload) => { + const {threadIds} = RepairPayload.parse(payload); + const {channelRepository} = getWorkerDependencies(); + await channelRepository.threads.repairThreadIndexes(threadIds.map((id) => createChannelID(BigInt(id)))); +}; + +export const deleteChannelThreads: WorkerTaskHandler = async (payload) => { + const validated = ParentPayload.parse(payload); + const guildId = createGuildID(BigInt(validated.guildId)); + const parentId = createChannelID(BigInt(validated.parentId)); + if (!everEnabled() || !(await isTainted(guildId, {fresh: true}))) return; + const {channelRepository, channelService} = getWorkerDependencies(); + const queue = archiveQueue(); + let deleted = 0; + let after: ChannelID | undefined; + while (true) { + const threadIds = await channelRepository.threads.listThreadIdsByParent(parentId, {after, limit: PARENT_PAGE_SIZE}); + if (threadIds.length === 0) break; + after = threadIds[threadIds.length - 1]; + const channels = new Map( + (await channelRepository.channelData.listChannels(threadIds)).map((channel) => [channel.id.toString(), channel]), + ); + for (const threadId of threadIds) { + const channel = channels.get(threadId.toString()); + if (channel) await channelService.attachments.purgeChannelAttachments(channel); + await deleteChannelMessageSearchDocuments(threadId, {context: {source: 'thread_parent_delete'}}); + await channelRepository.threads.purgeThread(threadId); + await queue.remove(guildId, threadId); + deleted++; + } + await deleteThreadSearchDocuments(threadIds); + } + Logger.info({guildId: guildId.toString(), parentId: parentId.toString(), deleted}, 'Deleted threads of parent'); +}; + +export const removeThreadMembershipsForGuildMember: WorkerTaskHandler = async (payload) => { + const validated = MemberPayload.parse(payload); + const guildId = createGuildID(BigInt(validated.guildId)); + const userId = createUserID(BigInt(validated.userId)); + if (!everEnabled() || !(await isTainted(guildId, {fresh: true}))) return; + const {channelRepository, guildRepository, gatewayService} = getWorkerDependencies(); + if (await guildRepository.getMember(guildId, userId)) return; + const threadIds = await channelRepository.threads.listJoinedThreadIds(userId, guildId); + for (const threadId of threadIds) { + const result = await channelRepository.threads.removeMembers(threadId, [userId]); + if (!result.state || result.removed.length === 0) continue; + const [view] = await loadThreadViews(channelRepository, [result.state]); + if (view) { + await dispatchThreadEvents(gatewayService, guildId, [threadMembersUpdateEvent(view, {removedUserIds: [userId]})]); + } + } +}; diff --git a/fluxer_api/src/api/worker/tasks/ThreadMentionScope.ts b/fluxer_api/src/api/worker/tasks/ThreadMentionScope.ts new file mode 100644 index 000000000..57fda313c --- /dev/null +++ b/fluxer_api/src/api/worker/tasks/ThreadMentionScope.ts @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {GuildID, MessageID, UserID} from '@app/api/BrandedTypes'; +import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; +import type { + CreateThreadMember, + IThreadRepository, + ThreadMemberAddResult, +} from '@app/api/channel/repositories/IThreadRepository'; +import {buildBroadcastMessageData} from '@app/api/channel/services/message/MessageGatewayDispatch'; +import {dispatchThreadEvents, threadMembersUpdateEvent} from '@app/api/channel/services/thread/ThreadDispatch'; +import {loadThreadView} from '@app/api/channel/services/thread/ThreadViews'; +import {threadMemberAutojoinTotal, threadRoleMentionCappedTotal} from '@app/api/channel/threads/ThreadMetrics'; +import { + guildActive, + recipientActive, + THREAD_CHANNEL_TYPES, + userActive, + userExcluded, +} from '@app/api/experiment/ChannelThreadsGate'; +import type {GatewayMentionSourceEntry, IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import type {Channel} from '@app/api/models/Channel'; +import type {ThreadState} from '@app/api/models/ThreadState'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import { + MAX_ROLE_MENTION_THREAD_ADDS, + MAX_THREAD_MEMBERS, + ServerMessageFlags, +} from '@fluxer/constants/src/ThreadConstants'; +import {MaxThreadMembersError} from '@fluxer/errors/src/domains/channel/MaxThreadMembersError'; + +export interface ThreadMentionScopeDeps { + channelRepository: IChannelRepository; + gatewayService: IGatewayService; + userRepository: IUserRepository; +} + +export async function threadRecipients( + userRepository: IUserRepository, + guildId: GuildID, + userIds: Array, +): Promise> { + const active = new Set(userIds.filter((userId) => recipientActive(guildId, userId, false))); + const maybeBots = userIds.filter((userId) => !active.has(userId) && !userActive(userId) && !userExcluded(userId)); + if (maybeBots.length > 0) { + for (const user of await userRepository.listUsers(maybeBots)) { + if (user.isBot && recipientActive(guildId, user.id, true)) active.add(user.id); + } + } + return active; +} + +export async function addThreadMembersWithinCap( + threads: IThreadRepository, + state: ThreadState, + members: Array, +): Promise { + const room = MAX_THREAD_MEMBERS - state.memberCount; + if (room <= 0) return null; + try { + return await threads.addMembers(state.threadId, members.slice(0, room)); + } catch (error) { + if (error instanceof MaxThreadMembersError) return null; + throw error; + } +} + +export class ThreadMentionScope { + private roleAdds = 0; + private roleCapped = false; + + private constructor( + private readonly deps: ThreadMentionScopeDeps, + private readonly channel: Channel, + private state: ThreadState | null, + private readonly messageId: MessageID, + ) {} + + static async load( + deps: ThreadMentionScopeDeps, + channel: Channel, + messageId: MessageID, + ): Promise { + if (channel.guildId === null || !THREAD_CHANNEL_TYPES.has(channel.type)) return null; + const state = guildActive(channel.guildId) ? await deps.channelRepository.threads.getState(channel.id) : null; + return new ThreadMentionScope(deps, channel, state, messageId); + } + + async filter(entries: Array): Promise> { + const {state} = this; + if (state === null || entries.length === 0) return []; + const recipients = await this.recipients(entries.map((entry) => entry.userId)); + const candidates = entries.filter((entry) => recipients.has(entry.userId)); + const members = new Set( + ( + await this.deps.channelRepository.threads.getMembers( + state.threadId, + candidates.map((entry) => entry.userId), + ) + ).map((member) => member.userId), + ); + const canAddRoleMembers = !state.isPrivate && !state.archived; + const kept: Array = []; + const roleAdds: Array = []; + for (const entry of candidates) { + if (entry.direct || members.has(entry.userId)) { + kept.push(entry); + continue; + } + if (!entry.role || !canAddRoleMembers) continue; + if (this.roleAdds + roleAdds.length >= MAX_ROLE_MENTION_THREAD_ADDS) { + this.roleCapped = true; + continue; + } + roleAdds.push(entry); + } + if (roleAdds.length === 0) return kept; + const joined = await this.addRoleMembers( + state, + roleAdds.map((entry) => entry.userId), + ); + for (const entry of roleAdds) { + if (joined.has(entry.userId)) kept.push({...entry, everyone: false}); + else this.roleCapped = true; + } + return kept; + } + + async finish(): Promise { + if (!this.roleCapped || this.channel.guildId === null) return; + threadRoleMentionCappedTotal.inc(); + const message = await this.deps.channelRepository.messages.getMessage(this.channel.id, this.messageId); + if (!message || (message.flags & ServerMessageFlags.FAILED_TO_MENTION_SOME_ROLES_IN_THREAD) !== 0) return; + const updated = await this.deps.channelRepository.messages.upsertMessage( + {...message.toRow(), flags: message.flags | ServerMessageFlags.FAILED_TO_MENTION_SOME_ROLES_IN_THREAD}, + message.toRow(), + ); + await dispatchThreadEvents(this.deps.gatewayService, this.channel.guildId, [ + { + event: 'MESSAGE_UPDATE', + data: await buildBroadcastMessageData({channel: this.channel, message: updated}), + }, + ]); + } + + private recipients(userIds: Array): Promise> { + return threadRecipients(this.deps.userRepository, this.channel.guildId!, userIds); + } + + private async addRoleMembers(state: ThreadState, userIds: Array): Promise> { + const {threads} = this.deps.channelRepository; + const result = await addThreadMembersWithinCap( + threads, + state, + userIds.map((userId) => ({userId, flags: 0})), + ); + const added = result?.added ?? []; + const joined = new Set(added.map((member) => member.userId)); + const missing = userIds.filter((userId) => !joined.has(userId)); + if (missing.length > 0) { + for (const member of await threads.getMembers(state.threadId, missing)) joined.add(member.userId); + } + if (!result || added.length === 0) return joined; + this.state = result.state; + this.roleAdds += added.length; + threadMemberAutojoinTotal.inc('source="role_mention"', added.length); + const parent = await this.deps.channelRepository.channelData.findUnique(state.parentId); + const view = parent + ? await loadThreadView(this.deps.channelRepository, this.channel, result.state, parent) + : {channel: this.channel, state: result.state, stats: await threads.getStats(state.threadId), parentType: null}; + await dispatchThreadEvents(this.deps.gatewayService, state.guildId, [threadMembersUpdateEvent(view, {added})]); + return joined; + } +} diff --git a/fluxer_api/src/api/worker/tasks/ThreadSearchTasks.ts b/fluxer_api/src/api/worker/tasks/ThreadSearchTasks.ts new file mode 100644 index 000000000..a61ca9322 --- /dev/null +++ b/fluxer_api/src/api/worker/tasks/ThreadSearchTasks.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createChannelID, createGuildID} from '@app/api/BrandedTypes'; +import {guildActive} from '@app/api/experiment/ChannelThreadsGate'; +import {Logger} from '@app/api/Logger'; +import { + backfillThreadSearch as backfillGuildThreadSearch, + syncThreadSearchDocument as syncDocument, +} from '@app/api/search/thread/ThreadSearchService'; +import {ensureChannelThreadsConfigVersion} from '@app/api/worker/tasks/SeedThreadPermissions'; +import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {z} from 'zod'; + +const SnowflakeString = z.string().regex(/^\d{1,20}$/); +const ThreadPayload = z.object({threadId: SnowflakeString}); +const GuildPayload = z.object({guildId: SnowflakeString, configVersion: z.number().int().min(0).optional()}); + +export const syncThreadSearchDocument: WorkerTaskHandler = async (payload) => { + const threadId = createChannelID(BigInt(ThreadPayload.parse(payload).threadId)); + await syncDocument(getWorkerDependencies().channelRepository, threadId); +}; + +export const backfillThreadSearch: WorkerTaskHandler = async (payload) => { + const validated = GuildPayload.parse(payload); + await ensureChannelThreadsConfigVersion(validated.configVersion); + const guildId = createGuildID(BigInt(validated.guildId)); + if (!guildActive(guildId)) return; + const {channelRepository} = getWorkerDependencies(); + if ((await channelRepository.threads.getGuildMarker(guildId))?.search_backfilled_at) return; + const indexed = await backfillGuildThreadSearch(channelRepository, guildId); + Logger.info({guildId: guildId.toString(), indexed}, 'Backfilled thread search index'); +}; diff --git a/fluxer_api/src/api/worker/tasks/utils/MessageDeletion.ts b/fluxer_api/src/api/worker/tasks/utils/MessageDeletion.ts index 4274c7777..6fc7e4c77 100644 --- a/fluxer_api/src/api/worker/tasks/utils/MessageDeletion.ts +++ b/fluxer_api/src/api/worker/tasks/utils/MessageDeletion.ts @@ -2,6 +2,8 @@ import {type ChannelID, createChannelID, type MessageID} from '@app/api/BrandedTypes'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; +import {withThreadContext} from '@app/api/channel/services/ChannelGatewayDispatch'; +import {decrementThreadMessageCount} from '@app/api/channel/services/message/MessageHelpers'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; interface BulkDeleteDispatcherDeps { @@ -30,15 +32,16 @@ export function createBulkDeleteDispatcher({channelRepository, gatewayService, b const channelId = createChannelID(BigInt(channelIdStr)); const channel = await channelRepository.findUnique(channelId); if (channel) { + await decrementThreadMessageCount(channelRepository, channel, messageIdsBatch); const payloadIds = messageIdsBatch.map((id) => id.toString()); if (channel.guildId) { await gatewayService.dispatchGuild({ guildId: channel.guildId, event: 'MESSAGE_DELETE_BULK', - data: { + data: withThreadContext(channel, { channel_id: channelIdStr, ids: payloadIds, - }, + }), }); } else { for (const recipientId of channel.recipientIds) { diff --git a/fluxer_api/src/api/worker/tests/CronScheduler.test.ts b/fluxer_api/src/api/worker/tests/CronScheduler.test.ts index 8ff8afbf4..703fbd50d 100644 --- a/fluxer_api/src/api/worker/tests/CronScheduler.test.ts +++ b/fluxer_api/src/api/worker/tests/CronScheduler.test.ts @@ -91,4 +91,27 @@ describe('CronScheduler', () => { expect(addJob).not.toHaveBeenCalled(); }); + + it('skips the lease and the enqueue while the enabled predicate is false', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const {scheduler, addJob, setnx} = createScheduler(); + let enabled = false; + scheduler.upsert('expireAttachments', 'expireAttachments', {}, '* * * * * *', { + ledger: false, + enabled: () => enabled, + }); + + scheduler.start(); + await vi.advanceTimersByTimeAsync(3000); + expect(setnx).not.toHaveBeenCalled(); + expect(addJob).not.toHaveBeenCalled(); + + enabled = true; + await vi.advanceTimersByTimeAsync(1000); + scheduler.stop(); + + expect(setnx).toHaveBeenCalledTimes(1); + expect(addJob).toHaveBeenCalledTimes(1); + }); }); diff --git a/fluxer_api/src/api/worker/tests/HandleMentionsThread.test.ts b/fluxer_api/src/api/worker/tests/HandleMentionsThread.test.ts new file mode 100644 index 000000000..c26c842e9 --- /dev/null +++ b/fluxer_api/src/api/worker/tests/HandleMentionsThread.test.ts @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type ChannelID, + createChannelID, + createGuildID, + createMessageID, + createUserID, + type UserID, +} from '@app/api/BrandedTypes'; +import type {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import {syncChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate'; +import type {GatewayMentionSourceEntry, IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import {ThreadState} from '@app/api/models/ThreadState'; +import type {ReadStateMentionUpdate} from '@app/api/read_state/IReadStateRepository'; +import type {ReadStateService} from '@app/api/read_state/ReadStateService'; +import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; +import type {UserRepository} from '@app/api/user/repositories/UserRepository'; +import handleMentionChunk from '@app/api/worker/tasks/HandleMentionChunk'; +import handleMentions from '@app/api/worker/tasks/HandleMentions'; +import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import { + MAX_ROLE_MENTION_THREAD_ADDS, + MAX_THREAD_MEMBERS, + ReadStateFlags, + ServerMessageFlags, +} from '@fluxer/constants/src/ThreadConstants'; +import {MaxThreadMembersError} from '@fluxer/errors/src/domains/channel/MaxThreadMembersError'; +import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; + +vi.mock('@app/api/channel/services/message/MessageGatewayDispatch', () => ({ + buildBroadcastMessageData: async ({message}: {message: {id: bigint; flags: number}}) => ({ + id: message.id.toString(), + flags: message.flags, + }), +})); + +vi.mock('@app/api/channel/services/thread/ThreadDispatch', async (importOriginal) => ({ + ...(await importOriginal()), + threadMembersUpdateEvent: ( + view: {state: {appliedTags: Array}}, + change: {added?: Array<{userId: UserID}>}, + ) => ({ + event: 'THREAD_MEMBERS_UPDATE', + data: { + added: (change.added ?? []).map((member) => member.userId.toString()), + ...(view.state.appliedTags.length > 0 ? {applied_tags: view.state.appliedTags.map(String)} : {}), + }, + }), +})); + +const GUILD = createGuildID(4000n); +const THREAD = createChannelID(5000n); +const PARENT = createChannelID(4500n); +const MESSAGE = createMessageID(6000n); +const AUTHOR = createUserID(1n); + +interface EnqueuedChunk { + thread?: boolean; + mentions: Array<{userId: string; direct: boolean; role: boolean; everyone: boolean}>; +} + +function activate(active: boolean): void { + const config = ChannelThreadsConfigSchema.parse( + active ? {enabled: true, guild_basis_points: 10000, user_basis_points: 10000} : {}, + ); + syncChannelThreadsConfig(JSON.stringify(config), (raw) => ChannelThreadsConfigSchema.parse(JSON.parse(raw ?? '{}'))); +} + +function createHarness(params: { + channelType?: number; + members?: Array; + isPrivate?: boolean; + memberCount?: number; + addRejected?: boolean; + joinedConcurrently?: Array; + forumTags?: {applied: Array; live: Array}; + mentions: Array; +}) { + const members = new Set(params.members ?? []); + const added: Array = []; + const dispatched: Array<{event: string; data: unknown}> = []; + const upserts: Array = []; + const message = { + id: MESSAGE, + flags: 0, + mentionEveryone: true, + mentionedRoleIds: new Set(), + mentionedUserIds: new Set(), + }; + const joinedConcurrently = new Set(params.joinedConcurrently ?? []); + const state = new ThreadState({ + thread_id: THREAD, + guild_id: GUILD, + parent_id: PARENT, + type: params.isPrivate ? ChannelTypes.PRIVATE_THREAD : ChannelTypes.PUBLIC_THREAD, + archived: false, + locked: false, + invitable: null, + auto_archive_duration: 1440, + archive_timestamp: null, + created_at: new Date(0), + flags: 0, + applied_tags: params.forumTags?.applied ?? null, + member_count: params.memberCount ?? members.size, + member_ids_preview: null, + has_starter: false, + state_version: 1, + }); + const parentType = params.forumTags ? ChannelTypes.GUILD_FORUM : ChannelTypes.GUILD_TEXT; + const channel = { + id: THREAD, + guildId: GUILD, + parentId: PARENT, + type: params.channelType ?? ChannelTypes.PUBLIC_THREAD, + }; + const channelRepository = { + getMessage: async () => message, + findUnique: async () => channel, + threads: { + getState: async () => state, + getMembers: async (_threadId: ChannelID, userIds: Array) => + userIds.filter((userId) => members.has(userId.toString())).map((userId) => ({userId})), + addMembers: async (_threadId: ChannelID, entries: Array<{userId: UserID}>) => { + if (params.addRejected) throw new MaxThreadMembersError(MAX_THREAD_MEMBERS); + for (const userId of joinedConcurrently) members.add(userId); + const fresh = entries.filter((entry) => !members.has(entry.userId.toString())); + for (const entry of fresh) added.push(entry.userId.toString()); + return {added: fresh.map((entry) => ({userId: entry.userId})), state}; + }, + getStats: async () => ({}), + getParentConfig: async () => ({availableTags: (params.forumTags?.live ?? []).map((id) => ({id}))}), + }, + channelData: { + findUnique: async () => ({ + id: PARENT, + type: parentType, + isThreadOnly: () => parentType === ChannelTypes.GUILD_FORUM, + }), + }, + messages: { + getMessage: async () => ({...message, toRow: () => ({flags: message.flags})}), + upsertMessage: async (row: {flags: number}) => { + upserts.push(row.flags); + return {...message, flags: row.flags}; + }, + }, + } as unknown as ChannelRepository; + const gatewayService = { + resolveMentionSourcesPage: async () => ({mentions: params.mentions, nextCursor: null}), + dispatchGuild: async ({event, data}: {event: string; data: unknown}) => { + dispatched.push({event, data}); + }, + dispatchGuildMany: async ({events}: {events: Array<{event: string; data: unknown}>}) => { + dispatched.push(...events); + }, + } as unknown as IGatewayService; + const userRepository = {listUsers: async () => []} as unknown as UserRepository; + setWorkerDependenciesForTest({channelRepository, gatewayService, userRepository}); + return {added, dispatched, upserts}; +} + +async function run(): Promise> { + const chunks: Array = []; + const helpers: WorkerTaskHelpers = { + logger: new NoopLogger(), + jobId: 1n, + addJob: async (_name: string, payload: unknown) => { + chunks.push(payload as EnqueuedChunk); + return 0n; + }, + reportProgress: async () => {}, + shouldCancel: async () => false, + setContextLink: async () => {}, + } as unknown as WorkerTaskHelpers; + await handleMentions( + { + channelId: THREAD.toString(), + messageId: MESSAGE.toString(), + authorId: AUTHOR.toString(), + guildId: GUILD.toString(), + }, + helpers, + ); + return chunks; +} + +function entry(userId: number, source: Partial): GatewayMentionSourceEntry { + return {userId: createUserID(BigInt(userId)), direct: false, role: false, everyone: false, ...source}; +} + +describe('thread mentions', () => { + beforeEach(() => activate(true)); + + afterEach(() => { + clearWorkerDependencies(); + activate(false); + }); + + test('notifies members for @everyone, keeps direct mentions and adds role members', async () => { + const harness = createHarness({ + members: ['10'], + mentions: [ + entry(10, {everyone: true}), + entry(11, {everyone: true}), + entry(12, {direct: true}), + entry(13, {role: true, everyone: true}), + ], + }); + const chunks = await run(); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.thread).toBe(true); + expect(chunks[0]?.mentions).toEqual([ + {userId: '10', direct: false, role: false, everyone: true}, + {userId: '12', direct: true, role: false, everyone: false}, + {userId: '13', direct: false, role: true, everyone: false}, + ]); + expect(harness.added).toEqual(['13']); + expect(harness.dispatched.map((event) => event.event)).toEqual(['THREAD_MEMBERS_UPDATE']); + expect(harness.upserts).toEqual([]); + }); + + test('keeps a role mention for a member who joined during the add', async () => { + const harness = createHarness({ + joinedConcurrently: ['13'], + mentions: [entry(13, {role: true}), entry(14, {role: true})], + }); + const chunks = await run(); + expect(harness.added).toEqual(['14']); + expect(chunks.flatMap((chunk) => chunk.mentions)).toEqual([ + {userId: '13', direct: false, role: true, everyone: false}, + {userId: '14', direct: false, role: true, everyone: false}, + ]); + expect(harness.upserts).toEqual([]); + }); + + test('drops deleted forum tags from the role add THREAD_MEMBERS_UPDATE', async () => { + const harness = createHarness({forumTags: {applied: [7n, 8n], live: [7n]}, mentions: [entry(13, {role: true})]}); + await run(); + expect(harness.dispatched).toEqual([{event: 'THREAD_MEMBERS_UPDATE', data: {added: ['13'], applied_tags: ['7']}}]); + }); + + test('never adds role members to a private thread', async () => { + const harness = createHarness({isPrivate: true, mentions: [entry(13, {role: true})]}); + expect(await run()).toEqual([]); + expect(harness.added).toEqual([]); + }); + + test('caps role adds and flags the message once', async () => { + const mentions = Array.from({length: MAX_ROLE_MENTION_THREAD_ADDS + 5}, (_, index) => + entry(100 + index, {role: true}), + ); + const harness = createHarness({mentions}); + const chunks = await run(); + expect(harness.added).toHaveLength(MAX_ROLE_MENTION_THREAD_ADDS); + expect(chunks.flatMap((chunk) => chunk.mentions)).toHaveLength(MAX_ROLE_MENTION_THREAD_ADDS); + expect(harness.upserts).toEqual([ServerMessageFlags.FAILED_TO_MENTION_SOME_ROLES_IN_THREAD]); + expect(harness.dispatched.at(-1)).toEqual({ + event: 'MESSAGE_UPDATE', + data: {id: MESSAGE.toString(), flags: ServerMessageFlags.FAILED_TO_MENTION_SOME_ROLES_IN_THREAD}, + }); + }); + + test('mentions only role members that fit under the thread member cap', async () => { + const harness = createHarness({ + memberCount: MAX_THREAD_MEMBERS - 1, + mentions: [entry(13, {role: true}), entry(14, {role: true})], + }); + const chunks = await run(); + expect(harness.added).toEqual(['13']); + expect(chunks.flatMap((chunk) => chunk.mentions)).toEqual([ + {userId: '13', direct: false, role: true, everyone: false}, + ]); + expect(harness.upserts).toEqual([ServerMessageFlags.FAILED_TO_MENTION_SOME_ROLES_IN_THREAD]); + }); + + test('mentions no role members when the add hits the member cap', async () => { + const harness = createHarness({addRejected: true, mentions: [entry(13, {role: true}), entry(12, {direct: true})]}); + const chunks = await run(); + expect(chunks.flatMap((chunk) => chunk.mentions)).toEqual([ + {userId: '12', direct: true, role: false, everyone: false}, + ]); + expect(harness.upserts).toEqual([ServerMessageFlags.FAILED_TO_MENTION_SOME_ROLES_IN_THREAD]); + }); + + test('mentions nobody in a thread once the guild leaves the experiment', async () => { + activate(false); + const harness = createHarness({mentions: [entry(12, {direct: true})]}); + expect(await run()).toEqual([]); + expect(harness.added).toEqual([]); + }); + + test('leaves ordinary channels untouched', async () => { + createHarness({channelType: ChannelTypes.GUILD_TEXT, mentions: [entry(11, {everyone: true})]}); + const chunks = await run(); + expect(chunks).toHaveLength(1); + expect(chunks[0]).not.toHaveProperty('thread'); + expect(chunks[0]?.mentions).toEqual([{userId: '11', direct: false, role: false, everyone: true}]); + }); +}); + +describe('thread mention chunks', () => { + afterEach(() => { + clearWorkerDependencies(); + activate(false); + }); + + function chunkHarness() { + const increments: Array = []; + const readStateService = { + bulkIncrementMentionCounts: async (updates: Array) => { + increments.push(...updates); + }, + } as unknown as ReadStateService; + const userRepository = { + findGuildSettings: async () => null, + createRecentMentions: async () => {}, + } as unknown as UserRepository; + setWorkerDependenciesForTest({readStateService, userRepository}); + return increments; + } + + async function runChunk() { + await handleMentionChunk( + { + channelId: THREAD.toString(), + messageId: MESSAGE.toString(), + guildId: GUILD.toString(), + thread: true, + mentions: [{userId: '12', direct: true}], + }, + {logger: new NoopLogger()} as unknown as WorkerTaskHelpers, + ); + } + + test('stamps thread read states on increment', async () => { + activate(true); + const increments = chunkHarness(); + await runChunk(); + expect(increments).toEqual([ + expect.objectContaining({ + userId: createUserID(12n), + marker: {flags: ReadStateFlags.IS_GUILD_CHANNEL | ReadStateFlags.IS_THREAD, guildId: GUILD}, + }), + ]); + }); + + test('drops a thread chunk that lands after the guild left the experiment', async () => { + const increments = chunkHarness(); + await runChunk(); + expect(increments).toEqual([]); + }); +}); diff --git a/fluxer_api/src/api/worker/tests/IndexChannelMessages.test.ts b/fluxer_api/src/api/worker/tests/IndexChannelMessages.test.ts index abb5ae35d..4ae757ae5 100644 --- a/fluxer_api/src/api/worker/tests/IndexChannelMessages.test.ts +++ b/fluxer_api/src/api/worker/tests/IndexChannelMessages.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {ChannelRepository} from '@app/api/channel/ChannelRepository'; +import type {Message} from '@app/api/models/Message'; import {setInjectedSearchProvider} from '@app/api/SearchFactory'; import type {IMessageSearchService} from '@app/api/search/IMessageSearchService'; import type {ISearchProvider} from '@app/api/search/ISearchProvider'; @@ -9,17 +10,23 @@ import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; import type {UserRepository} from '@app/api/user/repositories/UserRepository'; import indexChannelMessages from '@app/api/worker/tasks/IndexChannelMessages'; import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; +import {MessageTypes} from '@fluxer/constants/src/ChannelConstants'; import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; import {afterEach, describe, expect, it, vi} from 'vitest'; const COMPLETION_KEY = 'bulk_reindex:7000:remaining'; const HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers; -function createHarness(): {kvClient: MockKVProvider; refreshIndex: ReturnType} { +function createHarness(messages: Array = []): { + kvClient: MockKVProvider; + refreshIndex: ReturnType; + bulkIndexMessages: ReturnType; +} { const kvClient = new MockKVProvider(); const refreshIndex = vi.fn().mockResolvedValue(undefined); + const bulkIndexMessages = vi.fn().mockResolvedValue(undefined); const messageSearchService = { - bulkIndexMessages: async () => {}, + bulkIndexMessages, refreshIndex, } as unknown as IMessageSearchService; setInjectedSearchProvider({ @@ -28,12 +35,12 @@ function createHarness(): {kvClient: MockKVProvider; refreshIndex: ReturnType [], + listMessages: async (_channelId: unknown, cursor: unknown) => (cursor === undefined ? messages : []), findUnique: async () => null, } as unknown as ChannelRepository, userRepository: {listUsers: async () => []} as unknown as UserRepository, }); - return {kvClient, refreshIndex}; + return {kvClient, refreshIndex, bulkIndexMessages}; } describe('indexChannelMessages', () => { @@ -64,4 +71,16 @@ describe('indexChannelMessages', () => { expect(await kvClient.ttl(COMPLETION_KEY)).toBeGreaterThan(0); }); + it('skips thread created and thread starter notices', async () => { + const messages = [ + {id: 3n, authorId: null, type: MessageTypes.DEFAULT}, + {id: 2n, authorId: null, type: MessageTypes.THREAD_CREATED}, + {id: 1n, authorId: null, type: MessageTypes.THREAD_STARTER_MESSAGE}, + ] as unknown as Array; + const {bulkIndexMessages} = createHarness(messages); + + await indexChannelMessages({channelId: '5001'}, HELPERS); + + expect(bulkIndexMessages).toHaveBeenCalledWith([messages[0]], expect.any(Map)); + }); }); diff --git a/fluxer_api/src/api/worker/tests/SeedThreadPermissions.test.ts b/fluxer_api/src/api/worker/tests/SeedThreadPermissions.test.ts new file mode 100644 index 000000000..f6edc11eb --- /dev/null +++ b/fluxer_api/src/api/worker/tests/SeedThreadPermissions.test.ts @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID, createRoleID} from '@app/api/BrandedTypes'; +import {acceptInvite, createChannelInvite} from '@app/api/channel/tests/ChannelTestUtils'; +import {ALL_THREADS_ACTIVE, threadsRequest} from '@app/api/channel/tests/ThreadTestUtils'; +import {fetchOne} from '@app/api/database/CassandraQueryExecution'; +import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes'; +import { + clearChannelThreadsTaintCacheForTesting, + syncChannelThreadsConfig, +} from '@app/api/experiment/ChannelThreadsGate'; +import {addMemberRole, createGuild, createRole} from '@app/api/guild/tests/GuildTestUtils'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import { + getChannelRepository, + getGuildRepository, + getInstanceConfigRepository, + getUserCacheService, +} from '@app/api/middleware/ServiceSingletons'; +import {GuildThreadState} from '@app/api/Tables'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import seedThreadPermissions from '@app/api/worker/tasks/SeedThreadPermissions'; +import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes, DEFAULT_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {DEFAULT_THREAD_PERMISSIONS, ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; +import { + type ChannelThreadsConfig, + ChannelThreadsConfigSchema, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +function parseConfig(raw: string | null): ChannelThreadsConfig { + return ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}); +} + +function setConfig(patch: Partial | null): void { + syncChannelThreadsConfig(patch === null ? null : JSON.stringify(patch), parseConfig); + clearChannelThreadsTaintCacheForTesting(); +} + +function helpers(): WorkerTaskHelpers { + return { + logger: new NoopLogger(), + jobId: 1n, + addJob: async () => 0n, + reportProgress: async () => {}, + shouldCancel: async () => false, + setContextLink: async () => {}, + }; +} + +describe('seedThreadPermissions', () => { + let harness: ApiTestHarness; + let dispatchGuild: ReturnType; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + setConfig(null); + dispatchGuild = vi.fn(async () => {}); + setWorkerDependenciesForTest({ + channelRepository: getChannelRepository(), + guildRepository: getGuildRepository(), + userCacheService: getUserCacheService(), + instanceConfigRepository: getInstanceConfigRepository(), + gatewayService: {dispatchGuild} as unknown as IGatewayService, + }); + }); + + afterEach(() => { + setConfig(null); + }); + + afterAll(async () => { + clearWorkerDependencies(); + await harness?.shutdown(); + }); + + it('clears raw overwrite bits, mirrors send permission into the thread bits, and stays idempotent', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Seed'); + const guildId = createGuildID(BigInt(guild.id)); + const channel = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({ + name: 'announcements', + type: ChannelTypes.GUILD_TEXT, + permission_overwrites: [{id: guild.id, type: 0, allow: '0', deny: Permissions.SEND_MESSAGES.toString()}], + }) + .execute(); + const channelRepository = getChannelRepository(); + const stored = await channelRepository.findUnique(createChannelID(BigInt(channel.id))); + await channelRepository.upsert({ + ...stored!.toRow(), + permission_overwrites: new Map([ + [ + createRoleID(BigInt(guild.id)), + {type: 0, allow_: ThreadPermissionFlags.CREATE_PRIVATE_THREADS, deny_: Permissions.SEND_MESSAGES}, + ], + ]), + }); + + await seedThreadPermissions({guildId: guild.id}, helpers()); + expect(dispatchGuild).not.toHaveBeenCalled(); + expect( + await fetchOne( + GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1}), + {guild_id: guildId}, + ), + ).toBeNull(); + + setConfig({enabled: true, ever_enabled: true, enabled_guild_ids: [guild.id]}); + await seedThreadPermissions({guildId: guild.id}, helpers()); + const everyone = await getGuildRepository().getRole(createRoleID(BigInt(guild.id)), guildId); + expect(everyone?.permissions).toBe(DEFAULT_PERMISSIONS | DEFAULT_THREAD_PERMISSIONS); + const seeded = (await channelRepository.findUnique(createChannelID(BigInt(channel.id))))!.permissionOverwrites.get( + createRoleID(BigInt(guild.id)), + ); + expect(seeded?.allow).toBe(0n); + expect(seeded?.deny).toBe(Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS); + expect(dispatchGuild.mock.calls.map(([params]) => params.event)).toEqual([ + 'GUILD_ROLE_UPDATE_BULK', + 'CHANNEL_UPDATE_BULK', + ]); + const marker = await fetchOne( + GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1}), + {guild_id: guildId}, + ); + expect(marker?.perms_seeded_at).toBeInstanceOf(Date); + + await getGuildRepository().upsertRole({...everyone!.toRow(), permissions: DEFAULT_PERMISSIONS}); + await seedThreadPermissions({guildId: guild.id}, helpers()); + expect(dispatchGuild).toHaveBeenCalledTimes(2); + expect((await getGuildRepository().getRole(createRoleID(BigInt(guild.id)), guildId))?.permissions).toBe( + DEFAULT_PERMISSIONS, + ); + }); + + it('lets a role that is allowed to send in a read-only channel create threads and post in them', async () => { + const owner = await createTestAccount(harness); + const staff = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Staffed'); + const role = await createRole(harness, owner.token, guild.id, {name: 'Staff', permissions: '0'}); + const channel = await createBuilder(harness, owner.token) + .post(`/guilds/${guild.id}/channels`) + .body({ + name: 'announcements', + type: ChannelTypes.GUILD_TEXT, + permission_overwrites: [ + {id: guild.id, type: 0, allow: '0', deny: Permissions.SEND_MESSAGES.toString()}, + {id: role.id, type: 0, allow: Permissions.SEND_MESSAGES.toString(), deny: '0'}, + ], + }) + .execute(); + const invite = await createChannelInvite(harness, owner.token, channel.id); + await acceptInvite(harness, staff.token, invite.code); + await acceptInvite(harness, member.token, invite.code); + await addMemberRole(harness, owner.token, guild.id, staff.userId, role.id); + + setConfig({...ALL_THREADS_ACTIVE, ever_enabled: true, enabled_guild_ids: [guild.id]}); + await seedThreadPermissions({guildId: guild.id}, helpers()); + + const seeded = (await getChannelRepository().findUnique( + createChannelID(BigInt(channel.id)), + ))!.permissionOverwrites.get(createRoleID(BigInt(role.id))); + expect(seeded?.allow).toBe(Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS); + const thread = await threadsRequest(harness, staff.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'staff notes', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + await threadsRequest(harness, staff.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'hello'}) + .expect(200) + .execute(); + await threadsRequest(harness, member.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'sneaky', type: ChannelTypes.PUBLIC_THREAD}) + .expect(403, APIErrorCodes.MISSING_PERMISSIONS) + .execute(); + }); + + it('reloads a lagging config before deciding the guild is inactive, and retries while it stays behind', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Lagging'); + const guildId = createGuildID(BigInt(guild.id)); + const landed = await getInstanceConfigRepository().updateChannelThreadsConfig((current) => + ChannelThreadsConfigSchema.parse({ + enabled: true, + ever_enabled: true, + enabled_guild_ids: [guild.id], + config_version: current.config_version + 1, + }), + ); + setConfig(null); + + await expect( + seedThreadPermissions({guildId: guild.id, configVersion: landed.config_version + 1}, helpers()), + ).rejects.toThrow(); + setConfig(null); + + await seedThreadPermissions({guildId: guild.id, configVersion: landed.config_version}, helpers()); + const marker = await fetchOne( + GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1}), + {guild_id: guildId}, + ); + expect(marker?.perms_seeded_at).toBeInstanceOf(Date); + expect((await getGuildRepository().getRole(createRoleID(BigInt(guild.id)), guildId))?.permissions).toBe( + DEFAULT_PERMISSIONS | DEFAULT_THREAD_PERMISSIONS, + ); + }); +}); diff --git a/fluxer_api/src/api/worker/tests/ThreadMaintenanceSweeps.test.ts b/fluxer_api/src/api/worker/tests/ThreadMaintenanceSweeps.test.ts new file mode 100644 index 000000000..25053d89e --- /dev/null +++ b/fluxer_api/src/api/worker/tests/ThreadMaintenanceSweeps.test.ts @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import fs from 'node:fs'; +import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID, createUserID} from '@app/api/BrandedTypes'; +import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import {deleteOneOrMany, upsertOne} from '@app/api/database/CassandraQueryExecution'; +import {clearChannelThreadsTaintCacheForTesting, isTainted} from '@app/api/experiment/ChannelThreadsGate'; +import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils'; +import {getSnowflakeService} from '@app/api/middleware/ServiceRegistry'; +import {getChannelRepository} from '@app/api/middleware/ServiceSingletons'; +import {GuildThreadState} from '@app/api/Tables'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; +import {NoopWorkerService} from '@app/api/test/NoopWorkerService'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import deleteUserMessagesInGuildByTime from '@app/api/worker/tasks/DeleteUserMessagesInGuildByTime'; +import harvestGuildData from '@app/api/worker/tasks/HarvestGuildData'; +import {harvestMessages} from '@app/api/worker/tasks/HarvestUserData'; +import {clearWorkerDependencies, setWorkerDependencies} from '@app/api/worker/WorkerContext'; +import {initializeWorkerDependencies, type WorkerDependencies} from '@app/api/worker/WorkerDependencies'; +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +function helpers(): WorkerTaskHelpers { + return { + logger: new NoopLogger(), + jobId: 1n, + addJob: async () => 0n, + reportProgress: async () => {}, + shouldCancel: async () => false, + setContextLink: async () => {}, + attempt: {isLastAttempt: true}, + }; +} + +interface Setup { + owner: TestAccount; + member: TestAccount; + guildId: string; + channelId: string; + threadId: string; + messageIds: Array; +} + +describe('thread maintenance sweeps', () => { + let harness: ApiTestHarness; + let deps: WorkerDependencies; + let jobs: Array<{task: string; payload: Record}>; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + deps = await initializeWorkerDependencies(getSnowflakeService()); + setWorkerDependencies(deps); + jobs = []; + const addJob = async (task: string, payload: Record) => { + jobs.push({task, payload}); + if (task === 'deleteUserMessagesInGuildByTime') await deleteUserMessagesInGuildByTime(payload, helpers()); + return 0n; + }; + vi.spyOn(NoopWorkerService.prototype, 'addJob').mockImplementation(addJob as () => Promise); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + clearWorkerDependencies(); + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(): Promise { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'sweeps'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, channel.id); + await acceptInvite(harness, member.token, invite.code); + await ensureSessionStarted(harness, owner.token); + await ensureSessionStarted(harness, member.token); + const thread = await threadsRequest(harness, owner.token) + .post(`/channels/${channel.id}/threads`) + .body({name: 'sweep', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + const messageIds: Array = []; + for (const content of ['first', 'second']) { + const message = await threadsRequest(harness, member.token) + .post(`/channels/${thread.id}/messages`) + .body({content}) + .execute(); + messageIds.push(message.id); + } + return {owner, member, guildId: guild.id, channelId: channel.id, threadId: thread.id, messageIds}; + } + + async function remaining(threadId: string): Promise { + return (await getChannelRepository().messages.listMessages(createChannelID(BigInt(threadId)))).length; + } + + async function messageCount(threadId: string): Promise { + return (await getChannelRepository().threads.getStats(createChannelID(BigInt(threadId))))?.messageCount; + } + + async function ban(s: Setup): Promise { + await createBuilder(harness, s.owner.token) + .put(`/guilds/${s.guildId}/bans/${s.member.userId}`) + .body({delete_message_seconds: 3600}) + .expect(204) + .execute(); + } + + it('removes thread messages inside the ban delete window and decrements the count', async () => { + const s = await setup(); + expect(await messageCount(s.threadId)).toBe(2); + await ban(s); + expect(await remaining(s.threadId)).toBe(0); + expect(await messageCount(s.threadId)).toBe(0); + }); + + it('keeps sweeping thread messages of a tainted guild after the experiment is off', async () => { + const s = await setup(); + await setChannelThreadsConfig({enabled: false}); + await ban(s); + expect(await remaining(s.threadId)).toBe(0); + }); + + it('sweeps forum posts in the ban delete window while a clean taint answer is remembered', async () => { + const s = await setup(); + const forum = await threadsRequest(harness, s.owner.token) + .post(`/guilds/${s.guildId}/channels`) + .body({name: 'forum', type: ChannelTypes.GUILD_FORUM}) + .execute(); + const post = await threadsRequest(harness, s.member.token) + .post(`/channels/${forum.id}/threads`) + .body({name: 'post', message: {content: 'hello'}}) + .expect(201) + .execute(); + expect(await remaining(post.id)).toBe(1); + const guildId = createGuildID(BigInt(s.guildId)); + const marker = await getChannelRepository().threads.getGuildMarker(guildId); + await deleteOneOrMany(GuildThreadState.deleteByPk({guild_id: guildId})); + clearChannelThreadsTaintCacheForTesting(); + expect(await isTainted(guildId)).toBe(false); + await upsertOne(GuildThreadState.upsertAll(marker!)); + await ban(s); + expect(await remaining(post.id)).toBe(0); + expect(await remaining(s.threadId)).toBe(0); + }); + + it('removes thread messages in a guild scoped delete of my messages', async () => { + const s = await setup(); + await setChannelThreadsConfig({enabled: false}); + await deps.channelService.userMessageDeletion.deleteUserMessagesInScope(createUserID(BigInt(s.member.userId)), { + guildId: createGuildID(BigInt(s.guildId)), + }); + expect(await remaining(s.threadId)).toBe(0); + expect(await messageCount(s.threadId)).toBe(0); + }); + + it('includes thread messages in user and guild harvests', async () => { + const s = await setup(); + const harvested = await harvestMessages( + getChannelRepository(), + createUserID(BigInt(s.member.userId)), + Date.now(), + null, + ); + expect( + harvested.channelMessagesMap + .get(s.threadId) + ?.map((message) => message.id) + .sort(), + ).toEqual([...s.messageIds].sort()); + const admin = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]); + await createBuilder(harness, admin.token).post(`/admin/guilds/${s.guildId}/archives`).body({}).execute(); + const payload = jobs.find((job) => job.task === 'harvestGuildData')?.payload; + expect(payload).toBeDefined(); + vi.spyOn(deps.storageService, 'uploadObjectFromFile').mockResolvedValue(undefined as never); + const writeFile = vi.spyOn(fs.promises, 'writeFile'); + await harvestGuildData(payload!, helpers()); + const threadFile = writeFile.mock.calls.find(([file]) => + String(file).endsWith(`channels/${s.threadId}/messages.json`), + ); + expect(threadFile).toBeDefined(); + const written = JSON.parse(String(threadFile![1])) as Array<{id: string}>; + expect(written.map((message) => message.id).sort()).toEqual([...s.messageIds].sort()); + }); +}); diff --git a/fluxer_api/src/api/worker/tests/ThreadMaintenanceTasks.test.ts b/fluxer_api/src/api/worker/tests/ThreadMaintenanceTasks.test.ts new file mode 100644 index 000000000..12eb20b4b --- /dev/null +++ b/fluxer_api/src/api/worker/tests/ThreadMaintenanceTasks.test.ts @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createChannelID, createGuildID, createUserID} from '@app/api/BrandedTypes'; +import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/channel/tests/ChannelTestUtils'; +import { + ALL_THREADS_ACTIVE, + resetChannelThreadsConfig, + setChannelThreadsConfig, + threadsRequest, +} from '@app/api/channel/tests/ThreadTestUtils'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import {threadArchiveQueueKey} from '@app/api/infrastructure/KVThreadAutoArchiveQueueService'; +import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils'; +import {getKVClient} from '@app/api/middleware/ServiceRegistry'; +import { + getChannelRepository, + getGuildRepository, + getInstanceConfigRepository, +} from '@app/api/middleware/ServiceSingletons'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; +import { + archiveInactiveThreads, + deleteChannelThreads, + rebuildThreadAutoArchiveQueue, + removeThreadMembershipsForGuildMember, +} from '@app/api/worker/tasks/ThreadMaintenanceTasks'; +import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; +import type {WorkerDependencies} from '@app/api/worker/WorkerDependencies'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {ChannelFlags, DEFAULT_THREAD_AUTO_ARCHIVE_DURATION} from '@fluxer/constants/src/ThreadConstants'; +import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +function helpers(): WorkerTaskHelpers { + return { + logger: new NoopLogger(), + jobId: 1n, + addJob: async () => 0n, + reportProgress: async () => {}, + shouldCancel: async () => false, + setContextLink: async () => {}, + }; +} + +const PAST_ARCHIVE_WINDOW_MS = (DEFAULT_THREAD_AUTO_ARCHIVE_DURATION + 1) * 60_000; + +describe('thread maintenance tasks', () => { + let harness: ApiTestHarness; + let dispatchGuild: ReturnType; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + resetChannelThreadsConfig(); + dispatchGuild = vi.fn(async () => {}); + setWorkerDependenciesForTest({ + kvClient: getKVClient(), + channelRepository: getChannelRepository(), + guildRepository: getGuildRepository(), + instanceConfigRepository: getInstanceConfigRepository(), + gatewayService: {dispatchGuild, dispatchGuildMany: vi.fn(async () => {})} as unknown as IGatewayService, + channelService: { + attachments: {purgeChannelAttachments: vi.fn(async () => {})}, + } as unknown as WorkerDependencies['channelService'], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + clearWorkerDependencies(); + resetChannelThreadsConfig(); + await harness.shutdown(); + }); + + async function setup(): Promise<{owner: TestAccount; member: TestAccount; guildId: string; channelId: string}> { + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'sweep'); + const channel = await createChannel(harness, owner.token, guild.id, 'general'); + const invite = await createChannelInvite(harness, owner.token, channel.id); + await acceptInvite(harness, member.token, invite.code); + await ensureSessionStarted(harness, member.token); + return {owner, member, guildId: guild.id, channelId: channel.id}; + } + + async function startThread(token: string, channelId: string): Promise { + return threadsRequest(harness, token) + .post(`/channels/${channelId}/threads`) + .body({name: 'sweep me', type: ChannelTypes.PUBLIC_THREAD}) + .expect(201) + .execute(); + } + + function state(threadId: string) { + return getChannelRepository().threads.getState(createChannelID(BigInt(threadId))); + } + + it('archives inactive threads, skips pinned ones and freezes inactive guilds', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const fresh = await startThread(s.owner.token, s.channelId); + await getChannelRepository().threads.updateState(createChannelID(BigInt(fresh.id)), () => ({ + flags: ChannelFlags.PINNED, + })); + const now = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(now + PAST_ARCHIVE_WINDOW_MS); + await setChannelThreadsConfig({...ALL_THREADS_ACTIVE, guild_basis_points: 0}); + await archiveInactiveThreads({}, helpers()); + expect((await state(thread.id))?.archived).toBe(false); + await setChannelThreadsConfig(ALL_THREADS_ACTIVE); + await archiveInactiveThreads({}, helpers()); + expect((await state(thread.id))?.archived).toBe(true); + expect((await state(fresh.id))?.archived).toBe(false); + expect(dispatchGuild.mock.calls.some(([params]) => params.event === 'THREAD_UPDATE')).toBe(true); + const queued = await getKVClient().zrangebyscore( + threadArchiveQueueKey(createGuildID(BigInt(s.guildId))), + '-inf', + '+inf', + ); + expect(queued).not.toContain(thread.id); + }); + + it('reloads a lagging config before rebuilding the auto-archive queue', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + const key = threadArchiveQueueKey(createGuildID(BigInt(s.guildId))); + await getKVClient().del(key); + const configVersion = (await getInstanceConfigRepository().getChannelThreadsConfig()).config_version; + resetChannelThreadsConfig(); + await expect( + rebuildThreadAutoArchiveQueue({guildId: s.guildId, configVersion: configVersion + 1}, helpers()), + ).rejects.toThrow(); + resetChannelThreadsConfig(); + await rebuildThreadAutoArchiveQueue({guildId: s.guildId, configVersion}, helpers()); + expect(await getKVClient().zrangebyscore(key, '-inf', '+inf')).toContain(thread.id); + }); + + it('deletes the threads of a deleted parent in tainted guilds', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await deleteChannelThreads({guildId: s.guildId, parentId: s.channelId}, helpers()); + expect(await state(thread.id)).toBeNull(); + expect(await getChannelRepository().findUnique(createChannelID(BigInt(thread.id)))).toBeNull(); + }); + + it('purges thread memberships of a removed guild member and keeps those of a rejoined one', async () => { + const s = await setup(); + const thread = await startThread(s.owner.token, s.channelId); + await threadsRequest(harness, s.member.token) + .post(`/channels/${thread.id}/messages`) + .body({content: 'joined'}) + .execute(); + const threadId = createChannelID(BigInt(thread.id)); + const userId = createUserID(BigInt(s.member.userId)); + expect(await getChannelRepository().threads.getMember(threadId, userId)).not.toBeNull(); + await removeThreadMembershipsForGuildMember({guildId: s.guildId, userId: s.member.userId}, helpers()); + expect(await getChannelRepository().threads.getMember(threadId, userId)).not.toBeNull(); + await getGuildRepository().deleteMember(createGuildID(BigInt(s.guildId)), userId); + await removeThreadMembershipsForGuildMember({guildId: s.guildId, userId: s.member.userId}, helpers()); + expect(await getChannelRepository().threads.getMember(threadId, userId)).toBeNull(); + }); +}); diff --git a/fluxer_api/vitest.config.ts b/fluxer_api/vitest.config.ts index 2e62d0476..59a563cbb 100644 --- a/fluxer_api/vitest.config.ts +++ b/fluxer_api/vitest.config.ts @@ -28,6 +28,11 @@ const MODULE_REGISTRY_TEST_FILES = [ 'src/api/stripe/tests/StripeCheckoutCountryEnforcement.test.ts', 'src/api/stripe/tests/StripeNordicCurrencies.test.ts', 'src/api/worker/tests/CrosspostTasks.test.ts', + 'src/api/search/tests/ThreadSearchScopeCap.test.ts', + 'src/api/search/ThreadSearchScope.test.ts', + 'src/api/worker/tests/HandleMentionsThread.test.ts', + 'src/api/search/tests/GuildSearchThreadScope.test.ts', + 'src/api/worker/tasks/HarvestUserData.test.ts', ]; const INSTANCE_POLICY_TEST_FILES = [ diff --git a/fluxer_app/src/features/guild/utils/guild_tabs/audit_log/AuditLogPresentation.ts b/fluxer_app/src/features/guild/utils/guild_tabs/audit_log/AuditLogPresentation.ts index 52f9adaf4..316292e6f 100644 --- a/fluxer_app/src/features/guild/utils/guild_tabs/audit_log/AuditLogPresentation.ts +++ b/fluxer_app/src/features/guild/utils/guild_tabs/audit_log/AuditLogPresentation.ts @@ -53,6 +53,11 @@ import { presentRoleUpdate, } from '@app/features/guild/utils/guild_tabs/audit_log/AuditLogRolePresentation'; import {UNKNOWN_ACTION_SUMMARY} from '@app/features/guild/utils/guild_tabs/audit_log/AuditLogSharedMessages'; +import { + presentThreadCreate, + presentThreadDelete, + presentThreadUpdate, +} from '@app/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation'; import {actorPlaceholder, decodeAuditReason} from '@app/features/guild/utils/guild_tabs/audit_log/AuditLogValues'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import type {GuildAuditLogEntryResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; @@ -93,6 +98,9 @@ const PRESENTERS = { [AuditLogActionType.MESSAGE_BULK_DELETE]: presentMessageBulkDelete, [AuditLogActionType.MESSAGE_PIN]: presentMessagePin, [AuditLogActionType.MESSAGE_UNPIN]: presentMessageUnpin, + [AuditLogActionType.THREAD_CREATE]: presentThreadCreate, + [AuditLogActionType.THREAD_UPDATE]: presentThreadUpdate, + [AuditLogActionType.THREAD_DELETE]: presentThreadDelete, } satisfies Record; function presentUnknownAction(entry: GuildAuditLogEntryResponse): AuditLogDomainResult { diff --git a/fluxer_app/src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts b/fluxer_app/src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts new file mode 100644 index 000000000..9e99ca811 --- /dev/null +++ b/fluxer_app/src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { + AuditLogDetailRow, + AuditLogDomainResult, + AuditLogPlaceholder, +} from '@app/features/guild/utils/guild_tabs/audit_log/AuditLogPresentationTypes'; +import { + actorPlaceholder, + readBoolean, + readChange, + readNumber, + readSnowflake, + readString, +} from '@app/features/guild/utils/guild_tabs/audit_log/AuditLogValues'; +import type {GuildAuditLogEntryResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; +import {msg} from '@lingui/core/macro'; + +const THREAD_CREATE_SUMMARY = msg({ + message: '{actor} started the thread {channel}', + comment: + 'Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name.', +}); +const THREAD_UPDATE_SUMMARY = msg({ + message: '{actor} updated the thread {channel}', + comment: + 'Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name.', +}); +const THREAD_DELETE_SUMMARY = msg({ + message: '{actor} deleted the thread {channel}', + comment: + 'Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name.', +}); +const THREAD_NAME_CHANGED_ROW = msg({ + message: 'Changed the name from {oldName} to {newName}', + comment: + 'Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app.', +}); +const THREAD_ARCHIVED_ROW = msg({ + message: 'Closed the thread', + comment: + 'Activity log detail row under an updated thread that was archived. Past tense record, never an instruction.', +}); +const THREAD_UNARCHIVED_ROW = msg({ + message: 'Opened the thread again', + comment: + 'Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction.', +}); +const THREAD_LOCKED_ROW = msg({ + message: 'Locked the thread', + comment: 'Activity log detail row under an updated thread that was locked. Past tense record, never an instruction.', +}); +const THREAD_UNLOCKED_ROW = msg({ + message: 'Unlocked the thread', + comment: + 'Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction.', +}); +const THREAD_AUTO_ARCHIVE_CHANGED_ROW = msg({ + message: 'Changed the hide after inactivity time to {duration}', + comment: + 'Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction.', +}); +const THREAD_SLOWMODE_CHANGED_ROW = msg({ + message: 'Changed slowmode to {duration}', + comment: + 'Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction.', +}); + +function threadPlaceholder(entry: GuildAuditLogEntryResponse, key: 'newValue' | 'oldValue'): AuditLogPlaceholder { + return { + kind: 'channel', + id: readSnowflake(entry.target_id) ?? '', + recordedName: readString(readChange(entry, 'name')?.[key]), + fallback: 'channel', + }; +} + +function booleanRow( + entry: GuildAuditLogEntryResponse, + key: string, + onDescriptor: typeof THREAD_ARCHIVED_ROW, + offDescriptor: typeof THREAD_ARCHIVED_ROW, +): AuditLogDetailRow | null { + const change = readChange(entry, key); + if (change === null) return null; + const after = readBoolean(change.newValue); + if (after === null || after === readBoolean(change.oldValue)) return null; + return after + ? {id: key, tone: 'remove', sentence: {descriptor: onDescriptor, values: {}}} + : {id: key, tone: 'add', sentence: {descriptor: offDescriptor, values: {}}}; +} + +function durationRow( + entry: GuildAuditLogEntryResponse, + key: string, + descriptor: typeof THREAD_SLOWMODE_CHANGED_ROW, + secondsPerUnit: number, +): AuditLogDetailRow | null { + const change = readChange(entry, key); + if (change === null) return null; + const after = readNumber(change.newValue); + if (after === null || after === readNumber(change.oldValue)) return null; + return { + id: key, + tone: 'neutral', + sentence: {descriptor, values: {duration: {kind: 'duration', seconds: after * secondsPerUnit}}}, + }; +} + +function nameRow(entry: GuildAuditLogEntryResponse): AuditLogDetailRow | null { + const change = readChange(entry, 'name'); + const before = readString(change?.oldValue); + const after = readString(change?.newValue); + if (before === null || after === null || before === after) return null; + return { + id: 'name', + tone: 'neutral', + sentence: { + descriptor: THREAD_NAME_CHANGED_ROW, + values: {oldName: {kind: 'name', value: before}, newName: {kind: 'name', value: after}}, + }, + }; +} + +export function presentThreadCreate(entry: GuildAuditLogEntryResponse): AuditLogDomainResult { + return { + summary: { + descriptor: THREAD_CREATE_SUMMARY, + values: {actor: actorPlaceholder(entry), channel: threadPlaceholder(entry, 'newValue')}, + }, + rows: [], + blocks: [], + }; +} + +export function presentThreadUpdate(entry: GuildAuditLogEntryResponse): AuditLogDomainResult { + const rows = [ + nameRow(entry), + booleanRow(entry, 'archived', THREAD_ARCHIVED_ROW, THREAD_UNARCHIVED_ROW), + booleanRow(entry, 'locked', THREAD_LOCKED_ROW, THREAD_UNLOCKED_ROW), + durationRow(entry, 'auto_archive_duration', THREAD_AUTO_ARCHIVE_CHANGED_ROW, 60), + durationRow(entry, 'rate_limit_per_user', THREAD_SLOWMODE_CHANGED_ROW, 1), + ].filter((row): row is AuditLogDetailRow => row !== null); + return { + summary: { + descriptor: THREAD_UPDATE_SUMMARY, + values: {actor: actorPlaceholder(entry), channel: threadPlaceholder(entry, 'newValue')}, + }, + rows, + blocks: [], + }; +} + +export function presentThreadDelete(entry: GuildAuditLogEntryResponse): AuditLogDomainResult { + return { + summary: { + descriptor: THREAD_DELETE_SUMMARY, + values: {actor: actorPlaceholder(entry), channel: threadPlaceholder(entry, 'oldValue')}, + }, + rows: [], + blocks: [], + }; +} diff --git a/fluxer_app/src/features/i18n/locales/ar/messages.po b/fluxer_app/src/features/i18n/locales/ar/messages.po index bef4d1ffd..c7410e686 100644 --- a/fluxer_app/src/features/i18n/locales/ar/messages.po +++ b/fluxer_app/src/features/i18n/locales/ar/messages.po @@ -369,6 +369,11 @@ msgstr "قام {actor} بحذف الملصق {name}" msgid "{actor} deleted the text channel {channel}" msgstr "قام {actor} بحذف القناة النصية {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "قام {actor} بحذف الموضوع {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "قام {actor} بتعيين ضمائر ملفه الشخصي في الم msgid "{actor} set their nickname to {nick}" msgstr "قام {actor} بتغيير لقبه إلى {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "بدأ {actor} الموضوع {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "قام {actor} بتحديث الملصق {name}" msgid "{actor} updated the text channel {channel}" msgstr "قام {actor} بتحديث القناة النصية {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "قام {actor} بتحديث الموضوع {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "تغيير اسم المستخدم" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "تم تغيير الوضع البطيء من {oldDuration} إلى {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "تم تغيير وضع البطء إلى {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "تم تغيير الوصف من {oldText} إلى {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "تم تغيير فلتر المحتوى الصريح من {oldFilter} إلى {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "تم تغيير وقت الإخفاء بعد عدم النشاط إلى {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "تم تغيير حد سجل الرسائل من {oldDate} إلى {newDat #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "تم تغيير الاسم من {oldName} إلى {newName}" @@ -8441,6 +8468,11 @@ msgstr "الرسائل المباشرة المغلقة" msgid "Closed DMs" msgstr "الرسائل المباشرة المغلقة" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "تم إغلاق الموضوع" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "اللغة والمنطقة" msgid "Location" msgstr "الموقع" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "تم قفل الموضوع" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "افتح تطبيق {PRODUCT_NAME} القديم واختر \"ربط جه msgid "Open your settings" msgstr "فتح إعداداتك" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "تمت إعادة فتح المحادثة" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "افتح المزيد من الخلفيات مع {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "افتح الملصقات في الرسائل المباشرة مع {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "تم فتح الموضوع" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/auto-i18n-reviewed-unchanged.json b/fluxer_app/src/features/i18n/locales/auto-i18n-reviewed-unchanged.json index 64ffcb2bb..6b878766d 100644 --- a/fluxer_app/src/features/i18n/locales/auto-i18n-reviewed-unchanged.json +++ b/fluxer_app/src/features/i18n/locales/auto-i18n-reviewed-unchanged.json @@ -784,6 +784,9 @@ { "msgid": "{actor} banned {target}." }, + { + "msgid": "{actor} started the thread {channel}" + }, { "msgid": "{actor} stopped following an announcement channel in {channel}" }, @@ -1662,6 +1665,9 @@ { "msgid": "Changed slowmode from {oldDuration} to {newDuration}." }, + { + "msgid": "Changed slowmode to {duration}" + }, { "msgid": "Changed the AFK channel from {oldChannel} to {newChannel}." }, @@ -1719,6 +1725,9 @@ { "msgid": "Changed the explicit content filter from {oldFilter} to {newFilter}." }, + { + "msgid": "Changed the hide after inactivity time to {duration}" + }, { "msgid": "Changed the invite background alignment from {oldAlignment} to {newAlignment}." }, @@ -1839,6 +1848,9 @@ { "msgid": "Closed" }, + { + "msgid": "Closed the thread" + }, { "msgid": "Codec negotiation testing" }, @@ -2493,6 +2505,9 @@ { "msgid": "Loading instance configuration" }, + { + "msgid": "Locked the thread" + }, { "msgid": "Logo" }, @@ -3861,6 +3876,9 @@ { "msgid": "Unlink" }, + { + "msgid": "Unlocked the thread" + }, { "msgid": "Unread channels" }, @@ -4401,6 +4419,9 @@ { "msgid": "{actor} deleted the text channel {channel}." }, + { + "msgid": "{actor} deleted the thread {channel}" + }, { "msgid": "{actor} deleted the voice channel {channel}." }, @@ -4608,6 +4629,9 @@ { "msgid": "{actor} set their community pronouns to {text}." }, + { + "msgid": "{actor} started the thread {channel}" + }, { "msgid": "{actor} stopped following an announcement channel in {channel}" }, @@ -4683,6 +4707,9 @@ { "msgid": "{actor} updated the text channel {channel}." }, + { + "msgid": "{actor} updated the thread {channel}" + }, { "msgid": "{actor} updated the voice channel {channel}." }, diff --git a/fluxer_app/src/features/i18n/locales/bg/messages.po b/fluxer_app/src/features/i18n/locales/bg/messages.po index 6da9d73f7..bc850017a 100644 --- a/fluxer_app/src/features/i18n/locales/bg/messages.po +++ b/fluxer_app/src/features/i18n/locales/bg/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} изтри стикера {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} изтри текстовия канал {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} изтри темата {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} зададе местоименията на своя профи msgid "{actor} set their nickname to {nick}" msgstr "{actor} зададе своя прякор на {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} започна нишката {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} актуализира стикера {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} промени текстовия канал {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} актуализира темата {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Промяна на потребителско име" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Промени бавния режим от {oldDuration} на {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Промени бавния режим на {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Промени описанието от {oldText} на {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Променен е филтърът за изрично съдържание от {oldFilter} на {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Променено времето за скриване след неактивност на {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Променен е прагът на историята на съобщ #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Промени името от {oldName} на {newName}" @@ -8441,6 +8468,11 @@ msgstr "Затворени директни съобщения" msgid "Closed DMs" msgstr "Затворени DM" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Затвори нишката" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Локализация" msgid "Location" msgstr "Местоположение" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Заключих темата" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Отвори старото си приложение {PRODUCT_NAME} и msgid "Open your settings" msgstr "Отваряне на настройките" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Отворих отново темата" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Отключи още фонове с {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Отключи стикери в DM с {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Отключих темата" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/cs/messages.po b/fluxer_app/src/features/i18n/locales/cs/messages.po index 5abbe0e92..8c91ccf4c 100644 --- a/fluxer_app/src/features/i18n/locales/cs/messages.po +++ b/fluxer_app/src/features/i18n/locales/cs/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} smazal(a) samolepku {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} smazal textový kanál {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} smazal vlákno {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} nastavil(a) zájmena svého komunitního profilu na {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} si nastavil přezdívku na {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} založil vlákno {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} upravil(a) samolepku {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} upravil textový kanál {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} aktualizoval vlákno {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Změnit uživatelské jméno" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Změněn pomalý režim z {oldDuration} na {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Změněn pomalý režim na {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Změněn popis z {oldText} na {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Změněn filtr explicitního obsahu z {oldFilter} na {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Změnili jsme dobu pro skrytí po neaktivitě na {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Změněn práh historie zpráv z {oldDate} na {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Změněn název z {oldName} na {newName}" @@ -8441,6 +8468,11 @@ msgstr "Zavřené soukromé konverzace" msgid "Closed DMs" msgstr "Zavřené soukromé konverzace" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Vlákno bylo uzavřeno" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Lokalizace" msgid "Location" msgstr "Poloha" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Vlákno uzamčeno" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Otevřete svou starou aplikaci {PRODUCT_NAME} a vyberte „Propojit nov msgid "Open your settings" msgstr "Otevřít nastavení" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Vlákno bylo znovu otevřeno" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Odemkněte více pozadí s {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Odemkněte samolepky v soukromých konverzacích s {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Vlákno bylo odemknuto" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/da/messages.po b/fluxer_app/src/features/i18n/locales/da/messages.po index a7791ec9f..f969c9599 100644 --- a/fluxer_app/src/features/i18n/locales/da/messages.po +++ b/fluxer_app/src/features/i18n/locales/da/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} slettede klistermærket {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} slettede tekstkanalen {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} slettede tråden {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} angav pronominerne på sin fællesskabsprofil til {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} angav sit kaldenavn til {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} startede tråden {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} opdaterede klistermærket {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} opdaterede tekstkanalen {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} opdaterede tråden {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Skift dit brugernavn" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Ændrede langsom tilstand fra {oldDuration} til {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Ændrede langsom tilstand til {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Ændrede beskrivelsen fra {oldText} til {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Ændrede filteret for eksplicit indhold fra {oldFilter} til {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Ændrede arkivering efter inaktivitetstid til {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Ændrede grænsen for beskedhistorik fra {oldDate} til {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Ændrede navnet fra {oldName} til {newName}" @@ -8441,6 +8468,11 @@ msgstr "Lukkede direkte beskeder" msgid "Closed DMs" msgstr "Lukkede direkte beskeder" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Lukket tråden" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Sprog og område" msgid "Location" msgstr "Sted" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Låste tråden" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Åbn din gamle {PRODUCT_NAME}-app, vælg \"Tilknyt en ny enhed\", og ind msgid "Open your settings" msgstr "Åbn dine indstillinger" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Genåbnede tråden" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Lås op for flere baggrunde med {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Lås op for klistermærker i private beskeder med {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Låste tråden op" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/de/messages.po b/fluxer_app/src/features/i18n/locales/de/messages.po index 87c51abf8..ba5bba0ad 100644 --- a/fluxer_app/src/features/i18n/locales/de/messages.po +++ b/fluxer_app/src/features/i18n/locales/de/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} hat den Sticker {name} gelöscht" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} hat den Textkanal {channel} gelöscht" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} hat den Thread {channel} gelöscht" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} hat die Pronomen für sein Community-Profil auf {text} gesetzt" msgid "{actor} set their nickname to {nick}" msgstr "{actor} hat seinen Spitznamen auf {nick} geändert" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} hat den Thread {channel} gestartet" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} hat den Sticker {name} aktualisiert" msgid "{actor} updated the text channel {channel}" msgstr "{actor} hat den Textkanal {channel} aktualisiert" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} hat den Thread {channel} aktualisiert" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Benutzernamen ändern" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Slowmode von {oldDuration} auf {newDuration} geändert" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Langsammodus geändert auf {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Die Beschreibung wurde von {oldText} zu {newText} geändert" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Der Filter für explizite Inhalte wurde von {oldFilter} auf {newFilter} geändert" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Die Archivierungszeit nach Inaktivität wurde auf {duration} geändert" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Die Nachrichtenverlaufsgrenze wurde von {oldDate} auf {newDate} geänder #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Den Namen von {oldName} zu {newName} geändert" @@ -8441,6 +8468,11 @@ msgstr "Geschlossene Direktnachrichten" msgid "Closed DMs" msgstr "Geschlossene DMs" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Thread geschlossen" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Lokalisierung" msgid "Location" msgstr "Standort" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Thread gesperrt" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Öffne deine alte {PRODUCT_NAME}-App, wähle „Neues Gerät verknüpfen msgid "Open your settings" msgstr "Einstellungen öffnen" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Thread wieder geöffnet" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Schalte mehr Hintergründe mit {premiumProductName} frei" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Sticker in DMs mit {premiumProductName} freischalten" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Den Thread entsperrt" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/el/messages.po b/fluxer_app/src/features/i18n/locales/el/messages.po index 3859e1c6a..e7e7198aa 100644 --- a/fluxer_app/src/features/i18n/locales/el/messages.po +++ b/fluxer_app/src/features/i18n/locales/el/messages.po @@ -369,6 +369,11 @@ msgstr "Ο/Η {actor} διέγραψε το αυτοκόλλητο {name}" msgid "{actor} deleted the text channel {channel}" msgstr "Ο/Η {actor} διέγραψε το κανάλι κειμένου {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "Ο χρήστης {actor} διέγραψε το νήμα {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "Ο/Η {actor} όρισε τις αντωνυμίες του προφίλ msgid "{actor} set their nickname to {nick}" msgstr "Ο/Η {actor} όρισε το ψευδώνυμό του/της σε {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "Το {actor} ξεκίνησε το νήμα {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "Ο/Η {actor} ενημέρωσε το αυτοκόλλητο {name}" msgid "{actor} updated the text channel {channel}" msgstr "Ο/Η {actor} ενημέρωσε το κανάλι κειμένου {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "Το {actor} ενημέρωσε το νήμα {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Αλλαγή ονόματος χρήστη" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Άλλαξε η αργή λειτουργία από {oldDuration} σε {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Άλλαξε η αργή λειτουργία σε {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Άλλαξε η περιγραφή από {oldText} σε {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Άλλαξε το φίλτρο ακατάλληλου περιεχομένου από {oldFilter} σε {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Άλλαξε ο χρόνος απόκρυψης μετά από αδράνεια σε {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Άλλαξε το όριο ιστορικού μηνυμάτων από #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Άλλαξε το όνομα από {oldName} σε {newName}" @@ -8441,6 +8468,11 @@ msgstr "Κλειστά απευθείας μηνύματα" msgid "Closed DMs" msgstr "Κλειστά DM" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Έκλεισε το νήμα" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Γλώσσα και περιοχή" msgid "Location" msgstr "Τοποθεσία" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Κλείδωσε το νήμα" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Ανοίξτε την παλιά σας εφαρμογή {PRODUCT_NAME}, msgid "Open your settings" msgstr "Άνοιγμα ρυθμίσεων" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Ξανανοίξαμε το νήμα" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Ξεκλειδώστε περισσότερα φόντα με το {prem msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Ξεκλειδώστε αυτοκόλλητα σε DM με το {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Ξεκλείδωσε το νήμα" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/en-GB/messages.po b/fluxer_app/src/features/i18n/locales/en-GB/messages.po index d8ac6e2d2..66d810e03 100644 --- a/fluxer_app/src/features/i18n/locales/en-GB/messages.po +++ b/fluxer_app/src/features/i18n/locales/en-GB/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} deleted the sticker {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} deleted the text channel {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} deleted the thread {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} set their community profile pronouns to {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} set their nickname to {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} started the thread {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} updated the sticker {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} updated the text channel {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} updated the thread {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Change your username" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Changed slowmode from {oldDuration} to {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Changed slowmode to {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Changed the description from {oldText} to {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Changed the explicit content filter from {oldFilter} to {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Changed the hide after inactivity time to {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Changed the message history threshold from {oldDate} to {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Changed the name from {oldName} to {newName}" @@ -8441,6 +8468,11 @@ msgstr "Closed direct messages" msgid "Closed DMs" msgstr "Closed DMs" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Closed the thread" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Localisation" msgid "Location" msgstr "Location" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Locked the thread" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Open your old {PRODUCT_NAME} app and choose Link a new device, then ente msgid "Open your settings" msgstr "Open your settings" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Reopened the thread" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,12 @@ msgstr "Unlock more backgrounds with {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Unlock stickers in DMs with {premiumProductName}" +# auto-i18n: reviewed unchanged +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Unlocked the thread" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 @@ -37702,7 +37750,6 @@ msgstr "Username must be at least 1 character" msgid "Username must be at most 32 characters" msgstr "Username must be at most 32 characters" -# auto-i18n: reviewed unchanged #. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain. #: src/features/app/components/setup/SetupWizardSteps.tsx:95 msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon." diff --git a/fluxer_app/src/features/i18n/locales/en-US/messages.po b/fluxer_app/src/features/i18n/locales/en-US/messages.po index c29f3090c..f7807fe21 100644 --- a/fluxer_app/src/features/i18n/locales/en-US/messages.po +++ b/fluxer_app/src/features/i18n/locales/en-US/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} deleted the sticker {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} deleted the text channel {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} deleted the thread {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} set their community profile pronouns to {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} set their nickname to {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} started the thread {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} updated the sticker {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} updated the text channel {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} updated the thread {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Change your username" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Changed slowmode from {oldDuration} to {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Changed slowmode to {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Changed the description from {oldText} to {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Changed the explicit content filter from {oldFilter} to {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Changed the hide after inactivity time to {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Changed the message history threshold from {oldDate} to {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Changed the name from {oldName} to {newName}" @@ -8441,6 +8468,11 @@ msgstr "Closed direct messages" msgid "Closed DMs" msgstr "Closed DMs" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Closed the thread" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Localization" msgid "Location" msgstr "Location" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Locked the thread" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Open your old {PRODUCT_NAME} app and choose Link a new device, then ente msgid "Open your settings" msgstr "Open your settings" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Opened the thread again" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Unlock more backgrounds with {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Unlock stickers in DMs with {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Unlocked the thread" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/es-419/messages.po b/fluxer_app/src/features/i18n/locales/es-419/messages.po index 28f115233..b051f7017 100644 --- a/fluxer_app/src/features/i18n/locales/es-419/messages.po +++ b/fluxer_app/src/features/i18n/locales/es-419/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} eliminó el sticker {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} eliminó el canal de texto {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} eliminó el hilo {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} cambió los pronombres de su perfil de comunidad a {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} cambió su apodo a {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} inició el hilo {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} actualizó el sticker {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} actualizó el canal de texto {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} actualizó el hilo {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Cambiar tu nombre de usuario" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Se cambió el modo lento de {oldDuration} a {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Se cambió el modo de cámara lenta a {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Se cambió la descripción de {oldText} a {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Se cambió el filtro de contenido explícito de {oldFilter} a {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Se cambió el tiempo de ocultar tras inactividad a {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Se cambió el umbral del historial de mensajes de {oldDate} a {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Se cambió el nombre de {oldName} a {newName}" @@ -8441,6 +8468,11 @@ msgstr "Mensajes directos cerrados" msgid "Closed DMs" msgstr "MD cerrados" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Se cerró el hilo" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Idioma y región" msgid "Location" msgstr "Ubicación" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Se bloqueó el hilo" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Abre tu antigua app de {PRODUCT_NAME}, selecciona \"Vincular un disposit msgid "Open your settings" msgstr "Abrir tu configuración" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Se volvió a abrir el hilo" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Desbloquea más fondos con {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Desbloquea stickers en los MD con {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Se desbloqueó el hilo" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/es-ES/messages.po b/fluxer_app/src/features/i18n/locales/es-ES/messages.po index 17f9116fb..90e6bafa5 100644 --- a/fluxer_app/src/features/i18n/locales/es-ES/messages.po +++ b/fluxer_app/src/features/i18n/locales/es-ES/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} eliminó la pegatina {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} eliminó el canal de texto {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} ha eliminado el hilo {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} estableció los pronombres de su perfil en la comunidad en {text msgid "{actor} set their nickname to {nick}" msgstr "{actor} estableció su apodo en {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} inició el hilo {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} ha actualizado la pegatina {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} actualizó el canal de texto {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} actualizó el hilo {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Cambiar tu nombre de usuario" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Se cambió el modo lento de {oldDuration} a {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Se cambió el modo de cámara lenta a {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Se cambió la descripción de {oldText} a {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Se cambió el filtro de contenido explícito de {oldFilter} a {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Se cambió el tiempo de ocultar tras inactividad a {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Se cambió el umbral del historial de mensajes de {oldDate} a {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Se cambió el nombre de {oldName} a {newName}" @@ -8441,6 +8468,11 @@ msgstr "Mensajes directos cerrados" msgid "Closed DMs" msgstr "MD cerrados" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Se cerró el hilo" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Idioma y región" msgid "Location" msgstr "Ubicación" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Se ha bloqueado el hilo" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Abre tu antigua app de {PRODUCT_NAME}, selecciona \"Vincular un disposit msgid "Open your settings" msgstr "Abrir ajustes" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Se ha vuelto a abrir el hilo" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Desbloquea más fondos con {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Desbloquea pegatinas en los MD con {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Se ha desbloqueado el hilo" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/fi/messages.po b/fluxer_app/src/features/i18n/locales/fi/messages.po index 5e0adf070..2c1d0111d 100644 --- a/fluxer_app/src/features/i18n/locales/fi/messages.po +++ b/fluxer_app/src/features/i18n/locales/fi/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} poisti tarran {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} poisti tekstikanavan {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} poisti säikeen {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} määritti yhteisöprofiilinsa pronominit muotoon {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} vaihtoi lempinimekseen {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} aloitti säikeen {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} päivitti tarran {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} päivitti tekstikanavan {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} päivitti säikeen {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Vaihda käyttäjänimesi" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Muutettiin hitaan tilan viive arvosta {oldDuration} arvoon {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Muutettiin hidastus {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Muutettiin kuvaus muodosta {oldText} muotoon {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Muutettiin arkaluonteisen sisällön suodatin asetuksesta {oldFilter} asetukseksi {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Piilotuksen aikaväli muuttui muotoon {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Muutettiin viestihistorian raja päivästä {oldDate} päivään {newDat #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Nimi muutettu muodosta {oldName} muotoon {newName}" @@ -8441,6 +8468,11 @@ msgstr "Suljetut yksityisviestit" msgid "Closed DMs" msgstr "Suljetut yksityisviestit" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Arkistoitu säie" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Lokalisointi" msgid "Location" msgstr "Sijainti" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Lukitsi säikeen" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Avaa vanha {PRODUCT_NAME}-sovelluksesi, valitse \"Linkitä uusi laite\" msgid "Open your settings" msgstr "Avaa asetukset" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Avasi säikeen uudelleen" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Avaa lisää taustakuvia {premiumProductName}-tilauksella" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Avaa tarrat yksityisviesteissä {premiumProductName}-tilauksella" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Säie avattu" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/fr/messages.po b/fluxer_app/src/features/i18n/locales/fr/messages.po index 89111e73a..8244dcba1 100644 --- a/fluxer_app/src/features/i18n/locales/fr/messages.po +++ b/fluxer_app/src/features/i18n/locales/fr/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} a supprimé le sticker {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} a supprimé le salon textuel {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} a supprimé le fil {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} a défini les pronoms de son profil dans la communauté sur {tex msgid "{actor} set their nickname to {nick}" msgstr "{actor} a défini son surnom sur {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} a démarré le fil {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} a mis à jour le sticker {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} a mis à jour le salon textuel {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} a mis à jour le fil {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Changer votre nom d'utilisateur" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Modification du mode lent de {oldDuration} à {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Modification du mode lent à {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "A modifié la description de {oldText} à {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "A modifié le filtre de contenu explicite de {oldFilter} à {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "A modifié le délai d'archivage automatique à {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "A modifié le seuil d'historique des messages de {oldDate} à {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "A changé le nom de {oldName} en {newName}" @@ -8441,6 +8468,11 @@ msgstr "Conversations privées fermées" msgid "Closed DMs" msgstr "MP fermés" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Fermé le fil" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Localisation" msgid "Location" msgstr "Localisation" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "A verrouillé le fil" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Ouvrez votre ancienne application {PRODUCT_NAME} et choisissez « Lier u msgid "Open your settings" msgstr "Ouvrir vos réglages" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "A rouvert le fil" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Débloquez plus d'arrière-plans avec {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Débloquez les stickers dans les MP avec {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "A déverrouillé le fil" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/he/messages.po b/fluxer_app/src/features/i18n/locales/he/messages.po index 86b0ebcbd..7372a26dd 100644 --- a/fluxer_app/src/features/i18n/locales/he/messages.po +++ b/fluxer_app/src/features/i18n/locales/he/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} מחק את המדבקה {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} מחק את ערוץ הטקסט {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} מחק את השרשור {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} הגדיר את כינויי הגוף של הפרופיל הקה msgid "{actor} set their nickname to {nick}" msgstr "{actor} שינה את הכינוי שלו ל-{nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} יצר את השרשור {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} עדכן את המדבקה {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} עדכן את ערוץ הטקסט {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} עדכן את השרשור {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "שינוי שם המשתמש שלך" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "שינוי המצב האיטי מ-{oldDuration} ל-{newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "שיניתי את מצב ההשהיה ל-{duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "שינוי התיאור מ-{oldText} ל-{newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "שינוי מסנן התוכן הבוטה מ-{oldFilter} ל-{newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "שיניתי את זמן ההסתרה לאחר חוסר פעילות ל-{duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "שינוי סף היסטוריית ההודעות מ-{oldDate} ל-{newDa #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "שינוי השם מ-{oldName} ל-{newName}" @@ -8441,6 +8468,11 @@ msgstr "הודעות פרטיות סגורות" msgid "Closed DMs" msgstr "הודעות פרטיות סגורות" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "סגר את השרשור" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "לוקליזציה" msgid "Location" msgstr "מיקום" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "נעילת השרשור" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "פתח את אפליקציית {PRODUCT_NAME} הישנה שלך ובחר msgid "Open your settings" msgstr "פתח את ההגדרות שלך" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "פתחתי את השרשור מחדש" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "קבל גישה לרקעים נוספים עם {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "קבל גישה למדבקות בהודעות פרטיות עם {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "פתח את השרשור" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/hi/messages.po b/fluxer_app/src/features/i18n/locales/hi/messages.po index bb772ad05..13d96b1ef 100644 --- a/fluxer_app/src/features/i18n/locales/hi/messages.po +++ b/fluxer_app/src/features/i18n/locales/hi/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} ने स्टिकर {name} हटा दिया" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} ने टेक्स्ट चैनल {channel} को हटा दिया" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} ने थ्रेड {channel} को हटा दिया" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} ने अपनी कम्युनिटी प्रोफ msgid "{actor} set their nickname to {nick}" msgstr "{actor} ने अपना उपनाम {nick} पर सेट किया" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} ने {channel} थ्रेड शुरू किया" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} ने स्टिकर {name} को अपडेट कि msgid "{actor} updated the text channel {channel}" msgstr "{actor} ने टेक्स्ट चैनल {channel} को अपडेट किया" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} ने थ्रेड {channel} को अपडेट किया" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "अपना यूज़रनेम बदलें" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "स्लोमोड {oldDuration} से बदलकर {newDuration} कर दिया गया" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "धीमा मोड {duration} में बदल दिया गया" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "विवरण {oldText} से बदलकर {newText} कर दि msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "अश्लील सामग्री फ़िल्टर को {oldFilter} से {newFilter} में बदल दिया गया" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "छिपाने की निष्क्रियता का समय बदलकर {duration} कर दिया गया" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "मैसेज हिस्ट्री थ्रेशोल्ड {ol #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "नाम {oldName} से बदलकर {newName} कर दिया गया" @@ -8441,6 +8468,11 @@ msgstr "बंद किए गए डायरेक्ट मैसेज" msgid "Closed DMs" msgstr "बंद किए गए DM" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "थ्रेड बंद कर दिया" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "लोकलाइज़ेशन" msgid "Location" msgstr "जगह" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "थ्रेड को लॉक कर दिया" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "अपना पुराना {PRODUCT_NAME} ऐप खोलें msgid "Open your settings" msgstr "अपनी सेटिंग्स खोलें" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "थ्रेड को फिर से खोला गया" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "{premiumProductName} के साथ और बैकग्राउं msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "{premiumProductName} के साथ DM में स्टिकर अनलॉक करें" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "थ्रेड को अनलॉक किया" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/hr/messages.po b/fluxer_app/src/features/i18n/locales/hr/messages.po index fa5bdb07b..87242269f 100644 --- a/fluxer_app/src/features/i18n/locales/hr/messages.po +++ b/fluxer_app/src/features/i18n/locales/hr/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} je izbrisao naljepnicu {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} je izbrisao tekstualni kanal {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} je izbrisao/izbrisala nit {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} je postavio/la svoje zamjenice za profil zajednice na {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} je promijenio svoj nadimak u {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} je započeo/la nit {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} je ažurirao naljepnicu {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} je ažurirao tekstualni kanal {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} je ažurirao/la nit {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Promijenite svoje korisničko ime" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Promijenjen je usporeni način rada s {oldDuration} na {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Promijenjen je usporeni način na {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Promijenjen je opis iz {oldText} u {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Promijenjen je filtar za eksplicitni sadržaj s {oldFilter} na {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Promijenjeno skrivanje nakon neaktivnosti na {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Promijenjen je prag povijesti poruka s {oldDate} na {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Promijenjeno ime iz {oldName} u {newName}" @@ -8441,6 +8468,11 @@ msgstr "Zatvorene izravne poruke" msgid "Closed DMs" msgstr "Zatvorene izravne poruke" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Nit zatvorena" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Lokalizacija" msgid "Location" msgstr "Lokacija" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Zaključao/la je nit" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Otvorite svoju staru aplikaciju {PRODUCT_NAME} i odaberite \"Poveži nov msgid "Open your settings" msgstr "Otvori postavke" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Ponovno otvorena nit" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Otključajte više pozadina uz {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Otključajte naljepnice u izravnim porukama uz {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Otključana nit" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/hu/messages.po b/fluxer_app/src/features/i18n/locales/hu/messages.po index 7cda6a4a1..e9c07c740 100644 --- a/fluxer_app/src/features/i18n/locales/hu/messages.po +++ b/fluxer_app/src/features/i18n/locales/hu/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} törölte a matricát: {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} törölte a(z) {channel} szöveges csatornát" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} törölte a szálat {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} beállította a közösségi profiljának névmásait erre: {tex msgid "{actor} set their nickname to {nick}" msgstr "{actor} beállította a becenevét erre: {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} elindította a szálat: {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} frissítette a(z) {name} matricát" msgid "{actor} updated the text channel {channel}" msgstr "{actor} módosította a(z) {channel} szöveges csatornát" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} frissítette a szálat: {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Felhasználónév módosítása" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Lassú mód módosítva erről: {oldDuration} erre: {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Módosítva a lassított mód {duration} értékre" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Megváltoztatta a leírást erről: {oldText} erre: {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Megváltoztatta az explicittartalom-szűrőt erről: {oldFilter} erre: {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Az inaktivitás utáni elrejtés ideje erre változott: {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Megváltoztatta az üzenettörténet küszöbértékét erről: {oldDate #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Megváltoztatta a nevét erről: {oldName} erre: {newName}" @@ -8441,6 +8468,11 @@ msgstr "Bezárt közvetlen beszélgetések" msgid "Closed DMs" msgstr "Bezárt közvetlen beszélgetések" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Lezárta a szálat" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Lokalizáció" msgid "Location" msgstr "Hely" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Lezárta a szálat" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Nyisd meg a régi {PRODUCT_NAME} alkalmazásodat, válaszd az „Új esz msgid "Open your settings" msgstr "Beállítások megnyitása" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Újra megnyitotta a szálat" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "További hátterek feloldása a(z) {premiumProductName} csomaggal" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Matricák feloldása közvetlen beszélgetésekben a(z) {premiumProductName} csomaggal" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Feloldotta a szálat" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/id/messages.po b/fluxer_app/src/features/i18n/locales/id/messages.po index 67dc2b45b..b47849c0e 100644 --- a/fluxer_app/src/features/i18n/locales/id/messages.po +++ b/fluxer_app/src/features/i18n/locales/id/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} menghapus stiker {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} menghapus saluran teks {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} menghapus thread {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} mengatur kata ganti profil komunitasnya menjadi {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} mengubah nama panggilannya menjadi {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} memulai utas {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} memperbarui stiker {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} memperbarui saluran teks {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} memperbarui utas {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Ubah nama penggunamu" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Mengubah mode lambat dari {oldDuration} menjadi {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Mengubah mode lambat menjadi {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Mengubah deskripsi dari {oldText} menjadi {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Mengubah filter konten eksplisit dari {oldFilter} ke {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Mengubah waktu sembunyikan setelah tidak aktif menjadi {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Mengubah batas riwayat pesan dari {oldDate} ke {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Mengubah nama dari {oldName} menjadi {newName}" @@ -8441,6 +8468,11 @@ msgstr "Pesan langsung yang ditutup" msgid "Closed DMs" msgstr "DM yang ditutup" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Menutup utas" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Lokalisasi" msgid "Location" msgstr "Lokasi" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Mengunci utas" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Buka aplikasi {PRODUCT_NAME} lamamu dan pilih Tautkan perangkat baru, la msgid "Open your settings" msgstr "Buka pengaturanmu" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Membuka kembali utas" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Buka lebih banyak latar belakang dengan {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Buka stiker di DM dengan {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Membuka kunci thread" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/it/messages.po b/fluxer_app/src/features/i18n/locales/it/messages.po index c7d4f81f8..9940e32e8 100644 --- a/fluxer_app/src/features/i18n/locales/it/messages.po +++ b/fluxer_app/src/features/i18n/locales/it/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} ha eliminato lo sticker {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} ha eliminato il canale testuale {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} ha eliminato il thread {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} ha impostato i pronomi del proprio profilo community su {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} ha impostato il proprio soprannome su {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} ha avviato il thread {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} ha aggiornato lo sticker {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} ha aggiornato il canale testuale {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} ha aggiornato il thread {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Cambia il tuo nome utente" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Modificata la modalità lenta da {oldDuration} a {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Modificata la modalità lenta a {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Modificata la descrizione da {oldText} a {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Modificato il filtro contenuti espliciti da {oldFilter} a {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Modificata la durata di archiviazione automatica in {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Modificata la soglia della cronologia dei messaggi da {oldDate} a {newDa #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Modificato il nome da {oldName} a {newName}" @@ -8441,6 +8468,11 @@ msgstr "DM chiusi" msgid "Closed DMs" msgstr "DM chiusi" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Thread chiuso" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Localizzazione" msgid "Location" msgstr "Posizione" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Thread bloccato" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Apri la tua vecchia app {PRODUCT_NAME} e scegli Collega un nuovo disposi msgid "Open your settings" msgstr "Apri le impostazioni" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Riattivata la discussione" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Sblocca più sfondi con {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Sblocca gli sticker nei DM con {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Thread sbloccato" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/ja/messages.po b/fluxer_app/src/features/i18n/locales/ja/messages.po index 220d70c3d..a048dc32f 100644 --- a/fluxer_app/src/features/i18n/locales/ja/messages.po +++ b/fluxer_app/src/features/i18n/locales/ja/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} がスタンプ {name} を削除しました" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} がテキストチャンネル {channel} を削除しました" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} がスレッド {channel} を削除しました" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} がコミュニティプロフィールの代名詞を {text} msgid "{actor} set their nickname to {nick}" msgstr "{actor} がニックネームを {nick} に変更しました" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} がスレッド {channel} を開始しました" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} がスタンプ {name} を更新しました" msgid "{actor} updated the text channel {channel}" msgstr "{actor} がテキストチャンネル {channel} を更新しました" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} がスレッド {channel} を更新しました" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "ユーザー名を変更" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "スローモードを {oldDuration} から {newDuration} に変更しました" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "スローモードを{duration}に変更しました" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "説明を {oldText} から {newText} に変更しました" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "不適切なコンテンツのフィルターを {oldFilter} から {newFilter} に変更しました" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "非アクティブ時の自動アーカイブ時間を {duration} に変更しました" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "メッセージ履歴のしきい値を {oldDate} から {newDate} に #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "「{oldName}」から「{newName}」に変更しました" @@ -8441,6 +8468,11 @@ msgstr "閉じたダイレクトメッセージ" msgid "Closed DMs" msgstr "閉じたDM" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "スレッドを閉じました" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "言語と地域" msgid "Location" msgstr "場所" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "スレッドをロックしました" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "古い{PRODUCT_NAME}アプリを開き、「新しいデバイスをリ msgid "Open your settings" msgstr "設定を開く" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "スレッドを再度開きました" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "{premiumProductName}でさらに多くの背景をアンロック" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "{premiumProductName}でDMのスタンプをアンロック" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "スレッドのロックを解除しました" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/ko/messages.po b/fluxer_app/src/features/i18n/locales/ko/messages.po index 8a594ef6a..96fb2e828 100644 --- a/fluxer_app/src/features/i18n/locales/ko/messages.po +++ b/fluxer_app/src/features/i18n/locales/ko/messages.po @@ -369,6 +369,11 @@ msgstr "{actor}님이 스티커 {name}을(를) 삭제했습니다" msgid "{actor} deleted the text channel {channel}" msgstr "{actor}님이 텍스트 채널 {channel}을(를) 삭제했습니다" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor}님이 스레드 {channel}을(를) 삭제했습니다." + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor}님이 커뮤니티 프로필의 대명사를 {text}(으)로 설 msgid "{actor} set their nickname to {nick}" msgstr "{actor}님이 닉네임을 {nick}(으)로 설정했습니다" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor}님이 {channel} 스레드를 시작했습니다." + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor}님이 스티커 {name}을(를) 업데이트했습니다" msgid "{actor} updated the text channel {channel}" msgstr "{actor}님이 텍스트 채널 {channel}을(를) 업데이트했습니다" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor}님이 스레드 {channel}을(를) 업데이트했습니다." + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "사용자 이름 변경" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "{oldDuration}에서 {newDuration}(으)로 슬로우 모드를 변경했습니다" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "슬로우 모드를 {duration}으로 변경했습니다." + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "설명을 {oldText}에서 {newText}(으)로 변경했습니다" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "음란물 필터를 {oldFilter}에서 {newFilter}(으)로 변경했습니다" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "비활성 후 숨김 시간을 {duration}(으)로 변경했습니다." + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "메시지 기록 공개 시점을 {oldDate}에서 {newDate}(으)로 변 #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "이름을 {oldName}에서 {newName}(으)로 변경했습니다" @@ -8441,6 +8468,11 @@ msgstr "닫은 DM" msgid "Closed DMs" msgstr "닫은 DM" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "스레드를 닫았습니다" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "언어 및 지역" msgid "Location" msgstr "위치" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "스레드를 잠갔습니다" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "기존 {PRODUCT_NAME} 앱을 열고 새 기기 연결을 선택한 다 msgid "Open your settings" msgstr "설정 열기" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "스레드를 다시 열었습니다" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "{premiumProductName}으로 더 많은 배경 잠금 해제" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "{premiumProductName}으로 DM에서 스티커를 잠금 해제하세요" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "스레드를 잠금 해제했습니다." + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/lt/messages.po b/fluxer_app/src/features/i18n/locales/lt/messages.po index c58d033dd..c676ee6c3 100644 --- a/fluxer_app/src/features/i18n/locales/lt/messages.po +++ b/fluxer_app/src/features/i18n/locales/lt/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} ištrynė lipduką {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} ištrynė tekstinį kanalą {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} ištrynė giją {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} pakeitė savo bendruomenės profilio įvardžius į {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} nustatė savo slapyvardį į {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} pradėjo pokalbį {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} pakeitė lipduką {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} pakeitė tekstinį kanalą {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} atnaujino giją {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Pakeisti naudotojo vardą" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Lėtasis režimas pakeistas iš {oldDuration} į {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Slowmode pakeistas į {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Pakeistas aprašymas iš {oldText} į {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Pakeistas nepadoraus turinio filtras iš {oldFilter} į {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Pakeistas slėpimo po neaktyvumo laikas į {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Pakeistas žinučių istorijos slenkstis iš {oldDate} į {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Pakeistas pavadinimas iš {oldName} į {newName}" @@ -8441,6 +8468,11 @@ msgstr "Uždarytos tiesioginės žinutės" msgid "Closed DMs" msgstr "Uždaryti DM pokalbiai" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Uždaryta tema" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20252,6 +20284,11 @@ msgstr "Lokalizacija" msgid "Location" msgstr "Vieta" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Užrakinta tema" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24624,6 +24661,11 @@ msgstr "Atidarykite savo senąją „{PRODUCT_NAME}“ programėlę ir pasirinki msgid "Open your settings" msgstr "Atidaryti savo nustatymus" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Vėl atidaryta tema" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36534,6 +36576,11 @@ msgstr "Atrakinkite daugiau fonų su „{premiumProductName}“" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Atrakinkite lipdukus DM pokalbiuose su „{premiumProductName}“" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Atrakinta gija" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/nl/messages.po b/fluxer_app/src/features/i18n/locales/nl/messages.po index df3311f05..e6b8292a8 100644 --- a/fluxer_app/src/features/i18n/locales/nl/messages.po +++ b/fluxer_app/src/features/i18n/locales/nl/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} heeft de sticker {name} verwijderd" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} heeft het tekstkanaal {channel} verwijderd" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} heeft de thread {channel} verwijderd" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} heeft de voornaamwoorden van hun communityprofiel ingesteld op { msgid "{actor} set their nickname to {nick}" msgstr "{actor} heeft hun bijnaam ingesteld op {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} startte de thread {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} heeft de sticker {name} bijgewerkt" msgid "{actor} updated the text channel {channel}" msgstr "{actor} heeft het tekstkanaal {channel} bijgewerkt" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} heeft de thread {channel} bijgewerkt" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Gebruikersnaam wijzigen" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Slowmodus gewijzigd van {oldDuration} naar {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Slowmode gewijzigd naar {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Beschrijving gewijzigd van {oldText} naar {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Het filter voor seksueel expliciete inhoud gewijzigd van {oldFilter} naar {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "De inactiviteitstijd voor verbergen is gewijzigd naar {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "De drempel voor berichtgeschiedenis gewijzigd van {oldDate} naar {newDat #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "De naam gewijzigd van {oldName} naar {newName}" @@ -8441,6 +8468,11 @@ msgstr "Gesloten DM-gesprekken" msgid "Closed DMs" msgstr "Gesloten DM's" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "De thread gesloten" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Taal en regio" msgid "Location" msgstr "Locatie" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "De thread vergrendeld" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Open je oude {PRODUCT_NAME}-app en kies 'Nieuw apparaat koppelen' en voe msgid "Open your settings" msgstr "Open je instellingen" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "De thread opnieuw geopend" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Ontgrendel meer achtergronden met {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Ontgrendel stickers in DM's met {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "De thread ontgrendeld" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/no/messages.po b/fluxer_app/src/features/i18n/locales/no/messages.po index 6499213ba..daac8ae71 100644 --- a/fluxer_app/src/features/i18n/locales/no/messages.po +++ b/fluxer_app/src/features/i18n/locales/no/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} slettet klistremerket {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} slettet tekstkanalen {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} slettet tråden {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} satte pronomenene på fellesskapsprofilen sin til {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} endret kallenavnet sitt til {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} startet tråden {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} oppdaterte klistremerket {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} oppdaterte tekstkanalen {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} oppdaterte tråden {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Endre brukernavnet ditt" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Endret sakte modus fra {oldDuration} til {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Endret sakte modus til {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Endret beskrivelsen fra {oldText} til {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Endret innstillingen for eksplisitt innhold fra {oldFilter} til {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Endret arkivering etter inaktivitet til {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Endret grensen for meldingshistorikk fra {oldDate} til {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Endret navnet fra {oldName} til {newName}" @@ -8441,6 +8468,11 @@ msgstr "Lukkede direktemeldingssamtaler" msgid "Closed DMs" msgstr "Lukkede direktemeldingssamtaler" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Lukket tråden" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Lokalisering" msgid "Location" msgstr "Sted" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Låste tråden" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Åpne din gamle {PRODUCT_NAME}-app og velg «Koble til en ny enhet», og msgid "Open your settings" msgstr "Åpne innstillingene dine" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Åpnet tråden igjen" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Lås opp flere bakgrunner med {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Lås opp klistremerker i direktemeldinger med {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Låste opp tråden" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/pl/messages.po b/fluxer_app/src/features/i18n/locales/pl/messages.po index a370538af..e1ffc4b5f 100644 --- a/fluxer_app/src/features/i18n/locales/pl/messages.po +++ b/fluxer_app/src/features/i18n/locales/pl/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} usunął naklejkę {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} usunął kanał tekstowy {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} usunął wątek {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} ustawił zaimki w swoim profilu społeczności na {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} ustawił swój pseudonim na {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} rozpoczął wątek {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} zaktualizował naklejkę {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} zaktualizował kanał tekstowy {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} zaktualizował wątek {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Zmień swoją nazwę użytkownika" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Zmieniono tryb wolny z {oldDuration} na {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Zmieniono tryb spowolnienia na {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Zmieniono opis z {oldText} na {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Zmieniono filtr treści dla dorosłych z {oldFilter} na {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Zmieniono czas ukrywania po braku aktywności na {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Zmieniono próg historii wiadomości z {oldDate} na {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Zmieniono nazwę z {oldName} na {newName}" @@ -8441,6 +8468,11 @@ msgstr "Zamknięte rozmowy prywatne" msgid "Closed DMs" msgstr "Zamknięte rozmowy prywatne" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Zamknięto wątek" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Lokalizacja" msgid "Location" msgstr "Lokalizacja" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Zablokowano wątek" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Otwórz starą aplikację {PRODUCT_NAME} i wybierz „Połącz nowe urz msgid "Open your settings" msgstr "Otwórz ustawienia" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Ponownie otwarto wątek" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Odblokuj więcej teł dzięki {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Odblokuj naklejki w wiadomościach prywatnych dzięki {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Odblokowano wątek" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/pt-BR/messages.po b/fluxer_app/src/features/i18n/locales/pt-BR/messages.po index effee39a1..3aea1da90 100644 --- a/fluxer_app/src/features/i18n/locales/pt-BR/messages.po +++ b/fluxer_app/src/features/i18n/locales/pt-BR/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} excluiu a figurinha {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} excluiu o canal de texto {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} excluiu o tópico {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} definiu os pronomes do perfil na comunidade como {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} mudou o apelido para {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} iniciou a thread {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} atualizou a figurinha {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} atualizou o canal de texto {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} atualizou a thread {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Alterar seu nome de usuário" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Alterou o modo lento de {oldDuration} para {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Alterou o modo de lentidão para {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Alterou a descrição de {oldText} para {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Alterou o filtro de conteúdo explícito de {oldFilter} para {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Alterou o tempo de ocultar após inatividade para {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Alterou o limite de histórico de mensagens de {oldDate} para {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Alterou o nome de {oldName} para {newName}" @@ -8441,6 +8468,11 @@ msgstr "Mensagens diretas fechadas" msgid "Closed DMs" msgstr "Conversas por mensagem direta fechadas" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Fechou a thread" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Localização do aplicativo" msgid "Location" msgstr "Localização" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Trancou o tópico" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Abra seu app antigo do {PRODUCT_NAME} e escolha \"Vincular um novo dispo msgid "Open your settings" msgstr "Abrir suas configurações" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Abriu o tópico novamente" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Desbloqueie mais planos de fundo com o {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Desbloqueie figurinhas em mensagens diretas com o {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Desbloqueou o tópico" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/ro/messages.po b/fluxer_app/src/features/i18n/locales/ro/messages.po index 876b40990..824f66b91 100644 --- a/fluxer_app/src/features/i18n/locales/ro/messages.po +++ b/fluxer_app/src/features/i18n/locales/ro/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} a șters stickerul {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} a șters canalul text {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} a șters firul de discuție {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} și-a setat pronumele în profilul comunității la {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} și-a setat pseudonimul la {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} a creat firul {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} a actualizat stickerul {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} a actualizat canalul text {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} a actualizat firul de discuție {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Schimbă-ți numele de utilizator" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "A modificat modul lent de la {oldDuration} la {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "A fost modificat modul lent la {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "A modificat descrierea din {oldText} în {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "A modificat filtrul de conținut explicit de la {oldFilter} la {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "A modificat ascunderea după inactivitate la {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "A modificat pragul istoricului mesajelor de la {oldDate} la {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "A modificat numele din {oldName} în {newName}" @@ -8441,6 +8468,11 @@ msgstr "Mesaje directe închise" msgid "Closed DMs" msgstr "DM-uri închise" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "A închis firul" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Localizare" msgid "Location" msgstr "Locație" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Firul a fost blocat" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Deschide vechea ta aplicație {PRODUCT_NAME} și alege „Asociază un d msgid "Open your settings" msgstr "Deschide setările" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Am redeschis firul" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Deblochează mai multe fundaluri cu {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Deblochează stickerele în DM-uri cu {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "A deblocat firul" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/ru/messages.po b/fluxer_app/src/features/i18n/locales/ru/messages.po index 7d264d0d2..aa3a10846 100644 --- a/fluxer_app/src/features/i18n/locales/ru/messages.po +++ b/fluxer_app/src/features/i18n/locales/ru/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} удалил(а) стикер {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} удалил(а) текстовый канал {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} удалил(а) ветку {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} установил(а) местоимения в профиле msgid "{actor} set their nickname to {nick}" msgstr "{actor} установил(а) себе никнейм {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} создал(а) ветку {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} обновил(а) стикер {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} обновил(а) текстовый канал {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} обновил(а) ветку {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Изменить имя пользователя" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Изменён медленный режим с {oldDuration} на {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Изменен медленный режим на {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Изменено описание с {oldText} на {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Изменен фильтр откровенного контента с {oldFilter} на {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Изменено время автоархивации до {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Изменён порог истории сообщений с {oldDate} #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Изменено название с {oldName} на {newName}" @@ -8441,6 +8468,11 @@ msgstr "Закрытые личные чаты" msgid "Closed DMs" msgstr "Закрытые личные чаты" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Закрыл(а) ветку" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Локализация" msgid "Location" msgstr "Местоположение" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Тема заблокирована" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Откройте старое приложение {PRODUCT_NAME} и в msgid "Open your settings" msgstr "Открыть настройки" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Снова открыли тему" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Разблокируйте больше фонов с {premiumProductNam msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Разблокируйте стикеры в личных сообщениях с {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Разблокировал(а) ветку" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/sv-SE/messages.po b/fluxer_app/src/features/i18n/locales/sv-SE/messages.po index c36d35232..4e39087cb 100644 --- a/fluxer_app/src/features/i18n/locales/sv-SE/messages.po +++ b/fluxer_app/src/features/i18n/locales/sv-SE/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} tog bort klistermärket {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} raderade textkanalen {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} raderade tråden {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} ändrade pronomenen för sin communityprofil till {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} ändrade sitt smeknamn till {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} startade tråden {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} uppdaterade klistermärket {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} uppdaterade textkanalen {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} uppdaterade tråden {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Ändra ditt användarnamn" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Ändrade långsamt läge från {oldDuration} till {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Ändrade långsamt läge till {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Ändrade beskrivningen från {oldText} till {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Ändrade filtret för explicit innehåll från {oldFilter} till {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Ändrade tiden för automatisk arkivering till {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Ändrade gränsdatumet för meddelandehistorik från {oldDate} till {new #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Ändrade namnet från {oldName} till {newName}" @@ -8441,6 +8468,11 @@ msgstr "Stängda DM-konversationer" msgid "Closed DMs" msgstr "Stängda DM-konversationer" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Tråden stängdes" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Språk och region" msgid "Location" msgstr "Plats" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Tråden låstes" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Öppna din gamla {PRODUCT_NAME}-app, välj Länka en ny enhet och ange s msgid "Open your settings" msgstr "Öppna dina inställningar" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Öppnade tråden igen" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Lås upp fler bakgrunder med {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Lås upp klistermärken i DM med {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Tråden låstes upp" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/th/messages.po b/fluxer_app/src/features/i18n/locales/th/messages.po index 609eb5bef..024da49ec 100644 --- a/fluxer_app/src/features/i18n/locales/th/messages.po +++ b/fluxer_app/src/features/i18n/locales/th/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} ลบสติกเกอร์ {name} แล้ว" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} ลบช่องข้อความ {channel} แล้ว" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} ลบเธรด {channel} แล้ว" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} ตั้งค่าคำสรรพนามในโปร msgid "{actor} set their nickname to {nick}" msgstr "{actor} ตั้งชื่อเล่นของตนเองเป็น {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} เริ่มเธรด {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} อัปเดตสติกเกอร์ {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} อัปเดตช่องข้อความ {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} อัปเดตเธรด {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "เปลี่ยนชื่อผู้ใช้" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "เปลี่ยนโหมดจำกัดเวลาจาก {oldDuration} เป็น {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "เปลี่ยนโหมดหน่วงเวลาเป็น {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "เปลี่ยนคำอธิบายจาก {oldText} เป msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "เปลี่ยนการตั้งค่าตัวกรองเนื้อหาที่โจ่งแจ้งจาก {oldFilter} เป็น {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "เปลี่ยนเวลาซ่อนหลังไม่มีความเคลื่อนไหวเป็น {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "เปลี่ยนเกณฑ์ประวัติข้อคว #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "เปลี่ยนชื่อจาก {oldName} เป็น {newName}" @@ -8441,6 +8468,11 @@ msgstr "ข้อความส่วนตัวที่ปิดไปแล msgid "Closed DMs" msgstr "ข้อความส่วนตัวที่ปิดไปแล้ว" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "ปิดเธรดแล้ว" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "ภาษา" msgid "Location" msgstr "ตำแหน่งที่ตั้ง" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "ล็อกเธรดแล้ว" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "เปิดแอป {PRODUCT_NAME} เดิมของคุณ แ msgid "Open your settings" msgstr "เปิดการตั้งค่า" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "เปิดเธรดอีกครั้ง" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "ปลดล็อกพื้นหลังเพิ่มเติม msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "ปลดล็อกสติกเกอร์ในข้อความส่วนตัวด้วย {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "ปลดล็อกเธรดแล้ว" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/tr/messages.po b/fluxer_app/src/features/i18n/locales/tr/messages.po index 4e4ee01df..144a057b5 100644 --- a/fluxer_app/src/features/i18n/locales/tr/messages.po +++ b/fluxer_app/src/features/i18n/locales/tr/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} {name} adlı çıkartmayı sildi" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} {channel} metin kanalını sildi" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} {channel} başlığını sildi" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor}, topluluk profilindeki zamirlerini {text} olarak ayarladı" msgid "{actor} set their nickname to {nick}" msgstr "{actor} kendi takma adını {nick} olarak ayarladı" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} {channel} başlattı" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} {name} adlı çıkartmayı güncelledi" msgid "{actor} updated the text channel {channel}" msgstr "{actor} {channel} metin kanalını güncelledi" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} {channel} başlığını güncelledi" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Kullanıcı adınızı değiştirin" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Yavaş modu {oldDuration} değerinden {newDuration} değerine değiştirdi" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Yavaş mod {duration} olarak değiştirildi" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Açıklama {oldText} yerine {newText} olarak değiştirildi" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Müstehcen içerik filtresini {oldFilter} yerine {newFilter} olarak değiştirdi" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "'{duration}' olarak otomatik arşivleme süresi değiştirildi" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Mesaj geçmişi eşiğini {oldDate} tarihinden {newDate} tarihine deği #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Ad {oldName} yerine {newName} olarak değiştirildi" @@ -8441,6 +8468,11 @@ msgstr "Kapatılan doğrudan mesajlar" msgid "Closed DMs" msgstr "Kapatılan DM'ler" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Konu kapatıldı" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Yerelleştirme" msgid "Location" msgstr "Konum" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Kanalı kilitle\"" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Eski {PRODUCT_NAME} uygulamanızı açın ve Yeni bir cihaz bağla seçe msgid "Open your settings" msgstr "Ayarları aç" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Konuyu tekrar açtı" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "{premiumProductName} ile daha fazla arka planın kilidini açın" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "{premiumProductName} ile DM'lerde çıkartmaların kilidini açın" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Konu kilidi kaldırıldı" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/uk/messages.po b/fluxer_app/src/features/i18n/locales/uk/messages.po index 914c8d598..db6fe6de8 100644 --- a/fluxer_app/src/features/i18n/locales/uk/messages.po +++ b/fluxer_app/src/features/i18n/locales/uk/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} видалив(-ла) стікер {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} видалив(-ла) текстовий канал {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} видалив(ла) гілку {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} змінив(-ла) займенники у своєму про msgid "{actor} set their nickname to {nick}" msgstr "{actor} встановив(-ла) собі псевдонім {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} створив(ла) гілку {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} оновив(-ла) стікер {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} оновив(-ла) текстовий канал {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} оновив(ла) гілку {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Змінити ім'я користувача" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Змінено повільний режим з {oldDuration} на {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Змінено повільний режим на {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Змінено опис з {oldText} на {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Змінено фільтр відвертого контенту з {oldFilter} на {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Змінено час автоматичного архівування до {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Змінено поріг історії повідомлень з {oldD #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Змінено назву з {oldName} на {newName}" @@ -8441,6 +8468,11 @@ msgstr "Закриті приватні чати" msgid "Closed DMs" msgstr "Закриті приватні чати" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Закрито гілку" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Локалізація" msgid "Location" msgstr "Місцезнаходження" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Заблоковано гілку" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Відкрийте свій старий застосунок {PRODUCT_N msgid "Open your settings" msgstr "Відкрийте налаштування" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Знову відкрили гілку" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Розблокуйте більше фонів з {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Розблокуйте стікери у приватних чатах з {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Розблоковано гілку" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/vi/messages.po b/fluxer_app/src/features/i18n/locales/vi/messages.po index cf4d44b1b..51dcfd104 100644 --- a/fluxer_app/src/features/i18n/locales/vi/messages.po +++ b/fluxer_app/src/features/i18n/locales/vi/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} đã xóa nhãn dán {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} đã xóa kênh văn bản {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} đã xóa luồng {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} đã đặt đại từ nhân xưng trong hồ sơ cộng đồn msgid "{actor} set their nickname to {nick}" msgstr "{actor} đã đặt biệt danh của họ thành {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} đã bắt đầu luồng {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} đã cập nhật nhãn dán {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} đã cập nhật kênh văn bản {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} đã cập nhật luồng {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "Đổi tên người dùng" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "Đã thay đổi chế độ chậm từ {oldDuration} thành {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "Đã thay đổi chế độ chậm thành {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "Đã thay đổi mô tả từ {oldText} thành {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "Đã thay đổi bộ lọc nội dung nhạy cảm từ {oldFilter} sang {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "Đã thay đổi thời gian ẩn sau khi không hoạt động thành {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "Đã thay đổi ngưỡng lịch sử tin nhắn từ {oldDate} thành #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "Đã đổi tên từ {oldName} thành {newName}" @@ -8441,6 +8468,11 @@ msgstr "Tin nhắn trực tiếp đã đóng" msgid "Closed DMs" msgstr "Tin nhắn trực tiếp đã đóng" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "Đã đóng luồng" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "Bản địa hóa" msgid "Location" msgstr "Vị trí" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "Đã khóa luồng" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "Mở ứng dụng {PRODUCT_NAME} cũ của bạn và chọn Liên kết msgid "Open your settings" msgstr "Mở cài đặt của bạn" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "Đã mở lại luồng" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "Mở khóa thêm hình nền với {premiumProductName}" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "Mở khóa nhãn dán trong tin nhắn trực tiếp với {premiumProductName}" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "Đã mở khóa luồng" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/zh-CN/messages.po b/fluxer_app/src/features/i18n/locales/zh-CN/messages.po index e7c1d239b..a5b4f8f3c 100644 --- a/fluxer_app/src/features/i18n/locales/zh-CN/messages.po +++ b/fluxer_app/src/features/i18n/locales/zh-CN/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} 删除了贴纸 {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} 删除了文字频道 {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor}删除了频道 {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} 将其社区个人资料中的代词设置为 {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} 将昵称改为了 {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} 创建了主题 {channel}" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} 更新了贴纸 {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} 更新了文字频道 {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} 更新了频道 {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "更改用户名" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "将慢速模式从 {oldDuration} 更改为 {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "将慢速模式更改为 {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "更改了描述,从 {oldText} 改为 {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "已将露骨内容过滤器从 {oldFilter} 更改为 {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "将非活动时间隐藏时长更改为{duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "将消息历史记录阈值从 {oldDate} 更改为 {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "将名称从 {oldName} 更改为 {newName}" @@ -8441,6 +8468,11 @@ msgstr "已关闭的私信" msgid "Closed DMs" msgstr "已关闭的私信" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "关闭了该帖子" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "本地化" msgid "Location" msgstr "位置" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "已锁定该帖子" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "打开你的旧版 {PRODUCT_NAME} 应用,选择“关联新设备” msgid "Open your settings" msgstr "打开设置" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "重新打开了此帖子" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "使用 {premiumProductName} 解锁更多背景" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "使用 {premiumProductName} 解锁私信中的贴纸" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "解锁了该帖子" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_app/src/features/i18n/locales/zh-TW/messages.po b/fluxer_app/src/features/i18n/locales/zh-TW/messages.po index 0e953481c..6834c6050 100644 --- a/fluxer_app/src/features/i18n/locales/zh-TW/messages.po +++ b/fluxer_app/src/features/i18n/locales/zh-TW/messages.po @@ -369,6 +369,11 @@ msgstr "{actor} 刪除了貼圖 {name}" msgid "{actor} deleted the text channel {channel}" msgstr "{actor} 刪除了文字頻道 {channel}" +#. Activity log summary when a thread was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its recorded name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:29 +msgid "{actor} deleted the thread {channel}" +msgstr "{actor} 刪除了討論串 {channel}" + #. Activity log summary when a voice channel was deleted. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the deleted channel, shown as a channel chip with the name it had. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:336 msgid "{actor} deleted the voice channel {channel}" @@ -674,6 +679,11 @@ msgstr "{actor} 將其社群個人檔案的代名詞設為 {text}" msgid "{actor} set their nickname to {nick}" msgstr "{actor} 將自己的暱稱設為 {nick}" +#. Activity log summary when a thread was created. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:19 +msgid "{actor} started the thread {channel}" +msgstr "{actor} 在 {channel} 中建立了討論串" + #. Activity log summary for a moderator who stopped a member from hearing anyone in the voice channels of this community until it is undone. This is about what the member hears and not about the microphone of the member, so it must read clearly differently from the row where a moderator turns the microphone off. Describe the effect the way the English does. Do not name the action with a noun such as a community deafen. This is a past tense record of an action that already happened, never an instruction to the reader. {actor} is the member who stopped it, shown as a clickable user chip, or the word System. {target} is the member who can no longer hear others, shown as a clickable user chip. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogMemberMessages.ts:115 msgid "{actor} stopped {target} from hearing others in this community" @@ -809,6 +819,11 @@ msgstr "{actor} 更新了貼圖 {name}" msgid "{actor} updated the text channel {channel}" msgstr "{actor} 更新了文字頻道 {channel}" +#. Activity log summary when a thread was changed. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the thread, shown as a channel chip with its name. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:24 +msgid "{actor} updated the thread {channel}" +msgstr "{actor} 更新了討論串 {channel}" + #. Activity log summary when settings of a voice channel were changed. The changes are listed as detail rows below it. {actor} is the member who made the change, shown as a clickable user chip, or the word System. {channel} is the channel, shown as a channel chip with its name. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:199 msgid "{actor} updated the voice channel {channel}" @@ -7060,6 +7075,11 @@ msgstr "變更您的使用者名稱" msgid "Changed slowmode from {oldDuration} to {newDuration}" msgstr "將慢速模式從 {oldDuration} 變更為 {newDuration}" +#. Activity log detail row under an updated thread whose slowmode changed. {duration} is a formatted duration such as "30 seconds". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:63 +msgid "Changed slowmode to {duration}" +msgstr "已將慢速模式更改為 {duration}" + #. Activity log detail line under a community settings update, shown when the AFK channel, the voice channel idle members are moved to, was changed to another channel. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldChannel} is the previous AFK channel and {newChannel} is the new AFK channel, both shown as channel chips. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:161 msgid "Changed the AFK channel from {oldChannel} to {newChannel}" @@ -7135,6 +7155,11 @@ msgstr "變更了說明,從 {oldText} 改為 {newText}" msgid "Changed the explicit content filter from {oldFilter} to {newFilter}" msgstr "已將露骨內容篩選器從 {oldFilter} 變更為 {newFilter}" +#. Activity log detail row under an updated thread whose auto archive duration changed. {duration} is a formatted duration such as "1 hour". Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:58 +msgid "Changed the hide after inactivity time to {duration}" +msgstr "將閒置自動封存時間更改為 {duration}" + #. Activity log detail line under a community settings update, shown when the invite background, the image behind the invite page of the community, was replaced with a different image. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogGuildMessages.ts:96 msgid "Changed the invite background" @@ -7174,11 +7199,13 @@ msgstr "已將訊息紀錄門檻從 {oldDate} 變更為 {newDate}" #. Activity log detail line under an edited role that got a new name along with other changes. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the role name before the change and {newName} is the role name after it. Do not swap them. The app shows both in bold, so do not add ** or around them. #. Activity log detail line under an edited sticker, for a sticker that got a new name. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. Keep the word name so the line says what changed. {oldName} is the previous sticker name and {newName} is the new one, both shown in bold. The app adds the bold, so do not add ** or . #. Activity log detail row under an updated channel whose name changed along with other settings. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the name before the change and {newName} the name after, both shown in bold. The app adds the bold, so do not add ** or . +#. Activity log detail row under an updated thread whose name changed. It is a past tense record, never an instruction. {oldName} and {newName} are shown in bold by the app. #. Activity log detail under a changed webhook. It continues the entry summary, so it has no subject. It is a past tense record of a change the actor in the summary already made, never an instruction to the reader. Do not write it as a command, a button label or in first or second person. {oldName} is the previous name of the webhook and {newName} is its new name, both shown in bold by the app. Do not add ** or around them. #: src/features/guild/utils/guild_tabs/audit_log/AuditLogChannelMessages.ts:220 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogExpressionMessageMessages.ts:75 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogInviteWebhookMessages.ts:120 #: src/features/guild/utils/guild_tabs/audit_log/AuditLogRoleMessages.ts:55 +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:34 msgid "Changed the name from {oldName} to {newName}" msgstr "已將名稱從 {oldName} 變更為 {newName}" @@ -8441,6 +8468,11 @@ msgstr "已關閉的私訊" msgid "Closed DMs" msgstr "已關閉的私訊" +#. Activity log detail row under an updated thread that was archived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:39 +msgid "Closed the thread" +msgstr "封鎖了討論串" + #. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse. #: src/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels.ts:128 msgid "Cloud upload" @@ -20251,6 +20283,11 @@ msgstr "在地化" msgid "Location" msgstr "地點" +#. Activity log detail row under an updated thread that was locked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:49 +msgid "Locked the thread" +msgstr "鎖定了討論串" + #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #: src/features/user/components/settings_utils/search_index/DevicesIndex.ts:55 msgid "Log out" @@ -24623,6 +24660,11 @@ msgstr "開啟您的舊版 {PRODUCT_NAME} 應用程式,選擇「連結新裝 msgid "Open your settings" msgstr "開啟設定" +#. Activity log detail row under an updated thread that was unarchived. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:44 +msgid "Opened the thread again" +msgstr "重新開啟了討論串" + #. Screen-reader-only hint for the Members entry in community settings. The entry receives keyboard focus without automatically opening. #: src/features/app/components/dialogs/components/DesktopGuildSettingsView.tsx:66 msgid "Opens the Members page. Press Space or Enter to open." @@ -36533,6 +36575,11 @@ msgstr "使用 {premiumProductName} 解鎖更多背景" msgid "Unlock stickers in DMs with {premiumProductName}" msgstr "使用 {premiumProductName} 在私訊中解鎖貼圖" +#. Activity log detail row under an updated thread that was unlocked. Past tense record, never an instruction. +#: src/features/guild/utils/guild_tabs/audit_log/AuditLogThreadPresentation.ts:53 +msgid "Unlocked the thread" +msgstr "已解除鎖定討論串" + #. Channel details bottom sheet label or action (channel type, settings, toast, confirmation, or notification option). #: src/features/app/components/bottomsheets/MuteDurationSheet.tsx:72 #: src/features/channel/components/bottomsheets/channel_details_bottom_sheet/ChannelDetailsBottomSheetShared.ts:127 diff --git a/fluxer_docs/scripts/DocsSource.test.ts b/fluxer_docs/scripts/DocsSource.test.ts index bccccb043..03283b6f5 100644 --- a/fluxer_docs/scripts/DocsSource.test.ts +++ b/fluxer_docs/scripts/DocsSource.test.ts @@ -3,7 +3,7 @@ import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import path from 'node:path'; import {test} from 'node:test'; -import {listMarkdownFiles, slugifyHeading} from './DocsSource.ts'; +import {listDraftMarkdownFiles, listMarkdownFiles, slugifyHeading} from './DocsSource.ts'; test('Markdown discovery includes nested pages and excludes assets and dependencies', async (context) => { const directory = await mkdtemp(path.join(tmpdir(), 'fluxer-docs-source-')); @@ -22,6 +22,19 @@ test('Markdown discovery includes nested pages and excludes assets and dependenc await assert.rejects(listMarkdownFiles(path.join(directory, 'missing')), {code: 'ENOENT'}); }); +test('Markdown discovery keeps draft pages apart from published pages', async (context) => { + const directory = await mkdtemp(path.join(tmpdir(), 'fluxer-docs-source-')); + context.after(() => rm(directory, {recursive: true, force: true})); + await writeFile(path.join(directory, 'published.md'), '---\ntitle: Published\ndraft: false\n---\n'); + await writeFile(path.join(directory, 'withheld.mdx'), '---\ntitle: Withheld\ndraft: true\n---\n'); + await writeFile(path.join(directory, 'mentions.md'), '---\ntitle: Mentions\n---\n\ndraft: true\n'); + assert.deepEqual(await listMarkdownFiles(directory), [ + path.join(directory, 'mentions.md'), + path.join(directory, 'published.md'), + ]); + assert.deepEqual(await listDraftMarkdownFiles(directory), [path.join(directory, 'withheld.mdx')]); +}); + for (const [heading, expected] of [ [' JSON Body ', 'json-body'], ['`user_id` and [display name](/users)', 'userid-and-display-name'], diff --git a/fluxer_docs/scripts/DocsSource.ts b/fluxer_docs/scripts/DocsSource.ts index 8cb867173..e0a44e16c 100644 --- a/fluxer_docs/scripts/DocsSource.ts +++ b/fluxer_docs/scripts/DocsSource.ts @@ -1,6 +1,7 @@ import {readdir, readFile} from 'node:fs/promises'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; +import {parseFrontmatter} from '@astrojs/markdown-remark'; export const DOCS_ROOT = fileURLToPath(new URL('../src/content/docs/', import.meta.url)); export const HTTP_METHODS: ReadonlySet = new Set(['GET', 'HEAD', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS']); @@ -12,7 +13,7 @@ export interface MarkdownPage { readonly lines: ReadonlyArray; } -export async function listMarkdownFiles(directory: string): Promise> { +async function collectMarkdownFiles(directory: string): Promise> { const files: Array = []; for (const entry of await readdir(directory, {withFileTypes: true})) { if (entry.name === 'node_modules') { @@ -20,7 +21,7 @@ export async function listMarkdownFiles(directory: string): Promise> { +function isDraftSource(source: string): boolean { + return parseFrontmatter(source).frontmatter.draft === true; +} + +async function partitionMarkdownFiles(directory: string, drafts: boolean): Promise> { + const files: Array = []; + for (const file of await collectMarkdownFiles(directory)) { + if (isDraftSource(await readFile(file, 'utf8')) === drafts) { + files.push(file); + } + } + return files; +} + +export async function listMarkdownFiles(directory: string): Promise> { + return partitionMarkdownFiles(directory, false); +} + +export async function listDraftMarkdownFiles(directory: string): Promise> { + return partitionMarkdownFiles(directory, true); +} + +export async function readMarkdownPages( + directory: string, + options: {includeDrafts?: boolean} = {}, +): Promise> { const pages: Array = []; - for (const file of await listMarkdownFiles(directory)) { + const files = options.includeDrafts ? await collectMarkdownFiles(directory) : await listMarkdownFiles(directory); + for (const file of files) { const source = await readFile(file, 'utf8'); pages.push({ file, diff --git a/fluxer_docs/scripts/VerifyDocsCoverage.ts b/fluxer_docs/scripts/VerifyDocsCoverage.ts index 7ab185239..2a6053935 100644 --- a/fluxer_docs/scripts/VerifyDocsCoverage.ts +++ b/fluxer_docs/scripts/VerifyDocsCoverage.ts @@ -165,9 +165,66 @@ const OUT_OF_BAND_CREDENTIAL = new Map([ const HEALTH_AND_METRICS_PATHS = new Set(['/_health', '/_health/ready', '/_health/drain', '/_healthz', '/_metrics']); +const CHANNEL_THREADS_UNTIL_GA = { + routes: new Set([ + 'POST /channels/{}/messages/{}/threads', + 'POST /channels/{}/threads', + 'GET /guilds/{}/threads/active', + 'GET /channels/{}/threads/archived/public', + 'GET /channels/{}/threads/archived/private', + 'GET /channels/{}/users/@me/threads/archived/private', + 'GET /channels/{}/thread-members', + 'GET /channels/{}/thread-members/{}', + 'PUT /channels/{}/thread-members/@me', + 'PUT /channels/{}/thread-members/{}', + 'DELETE /channels/{}/thread-members/@me', + 'DELETE /channels/{}/thread-members/{}', + 'PATCH /channels/{}/thread-members/@me/settings', + 'GET /channels/{}/threads/search', + 'POST /channels/{}/post-data', + 'POST /channels/{}/tags', + 'PUT /channels/{}/tags/{}', + 'DELETE /channels/{}/tags/{}', + 'GET /admin/guilds/{}/threads', + 'DELETE /admin/channels/{}', + ]), + events: new Set([ + 'THREAD_CREATE', + 'THREAD_UPDATE', + 'THREAD_DELETE', + 'THREAD_LIST_SYNC', + 'THREAD_MEMBER_UPDATE', + 'THREAD_MEMBERS_UPDATE', + ]), + opcodes: new Set([28]), + errorCodes: new Set([ + 'CHANNEL_HAS_THREADS', + 'FORUM_TAG_NAMES_MUST_BE_UNIQUE', + 'FORUM_TAG_REQUIRED', + 'HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY', + 'INVALID_THREAD_NOTIFICATION_SETTINGS', + 'MAX_ACTIVE_THREADS', + 'MAX_FORUM_TAGS', + 'MAX_PINNED_THREADS_IN_FORUM', + 'MAX_THREAD_MEMBERS', + 'NO_TAGS_AVAILABLE_TO_NON_MODERATORS', + 'SEARCH_INDEX_NOT_READY', + 'THREAD_ALREADY_CREATED_FOR_MESSAGE', + 'THREAD_ARCHIVED', + 'THREAD_LOCKED', + 'UNKNOWN_FORUM_TAG', + 'UNKNOWN_THREAD_MEMBER', + 'WEBHOOK_FORUM_TARGET_CONFLICT', + 'WEBHOOK_FORUM_TARGET_REQUIRED', + 'WEBHOOK_SERVICE_FORUM_UNSUPPORTED', + 'WEBHOOK_THREAD_NAME_REQUIRES_FORUM', + ]), +}; + interface ExemptionRule { readonly name: string; readonly justification: string; + readonly mayCoverNothing?: boolean; readonly anchors: ReadonlyArray<{readonly file: string; readonly anchor: string}>; readonly covers: (shape: string, routePath: string) => boolean; @@ -219,6 +276,19 @@ const EXEMPTION_RULES: ReadonlyArray = [ anchors: [{file: 'fluxer_api/src/api/app/ControllerRegistry.ts', anchor: 'InternalRpcController(routes);'}], covers: (shape) => OUT_OF_BAND_CREDENTIAL.has(shape), }, + { + name: 'channel_threads', + justification: + 'threads, forum and media channels are behind the channel_threads experiment and ChannelThreadsRouteGuard answers 404 outside it. Their pages are withheld until general availability, which removes this rule', + mayCoverNothing: true, + anchors: [ + { + file: 'fluxer_api/src/api/channel/threads/ChannelThreadsRouteGuard.ts', + anchor: 'export function ChannelThreadsRouteGuard', + }, + ], + covers: (shape) => CHANNEL_THREADS_UNTIL_GA.routes.has(shape), + }, ]; const MEDIA_PROXY_ROUTES = new Map([ @@ -540,9 +610,9 @@ console.log('main API and admin API (from route registration)'); .filter(([shape]) => !registered.has(shape)) .map(([shape, route]) => `${shape} (${route.file}) is documented but fluxer_api registers no such route`) .sort(); - const deadRules = EXEMPTION_RULES.filter((rule) => (ruleCounts.get(rule.name) ?? 0) === 0).map( - (rule) => `the "${rule.name}" rule covers no registered route, so it is either stale or too narrow to matter`, - ); + const deadRules = EXEMPTION_RULES.filter( + (rule) => rule.mayCoverNothing !== true && (ruleCounts.get(rule.name) ?? 0) === 0, + ).map((rule) => `the "${rule.name}" rule covers no registered route, so it is either stale or too narrow to matter`); const staleEntries = [ ...[...DELIBERATELY_UNDOCUMENTED.keys()].map((shape) => [shape, 'DELIBERATELY_UNDOCUMENTED'] as const), ...[...OUT_OF_BAND_CREDENTIAL.keys()].map((shape) => [shape, 'OUT_OF_BAND_CREDENTIAL'] as const), @@ -592,7 +662,7 @@ failures += section( if (aliasShapes.has(shape)) { return false; } - return !DELIBERATELY_UNDOCUMENTED.has(shape); + return !DELIBERATELY_UNDOCUMENTED.has(shape) && !CHANNEL_THREADS_UNTIL_GA.routes.has(shape); }) .map((operation) => `${operation.method} ${operation.path} [${operation.tags.join(', ')}]`) .sort(), @@ -616,6 +686,12 @@ const wronglyDocumented = [...DELIBERATELY_UNDOCUMENTED.entries()] .filter(([shape]) => documentedMain.has(shape)) .map(([shape, reason]) => `${shape} must not be documented: ${reason}`); failures += section('documented despite belonging to an unshipped system', wronglyDocumented); +failures += section( + 'published despite being withheld until channel_threads general availability', + [...CHANNEL_THREADS_UNTIL_GA.routes] + .filter((shape) => documentedMain.has(shape) || documentedAdmin.has(shape)) + .sort(), +); console.log( ` deliberately undocumented and correctly absent: ${(DELIBERATELY_UNDOCUMENTED.size - wronglyDocumented.length).toString()}/${DELIBERATELY_UNDOCUMENTED.size.toString()}`, ); @@ -688,13 +764,19 @@ console.log('gateway dispatch events'); }; const fabricated = [...documentedEvents].filter((event) => !isReal(event)).sort(); - const undocumented = [...apiEvents].filter((event) => !documentedEvents.has(event)).sort(); + const undocumented = [...apiEvents] + .filter((event) => !documentedEvents.has(event) && !CHANNEL_THREADS_UNTIL_GA.events.has(event)) + .sort(); console.log(` events in the GatewayDispatchEvent union: ${apiEvents.size.toString()}`); console.log(` events documented in gateway/events.md: ${documentedEvents.size.toString()}`); console.log(' a documented event counts as real if it is in the union, or appears in fluxer_gateway'); console.log(' as an uppercase binary or a lowercase atom'); failures += section('documented but not emitted by any service', fabricated); failures += section('in the dispatch union but undocumented', undocumented); + failures += section( + 'published despite being withheld until channel_threads general availability', + [...CHANNEL_THREADS_UNTIL_GA.events].filter((event) => documentedEvents.has(event)).sort(), + ); } console.log('gateway opcodes and close codes'); @@ -734,10 +816,14 @@ console.log('gateway opcodes and close codes'); failures += section( 'opcodes in constants.erl but undocumented', [...realOpcodes] - .filter((code) => !docOpcodes.has(code)) + .filter((code) => !docOpcodes.has(code) && !CHANNEL_THREADS_UNTIL_GA.opcodes.has(code)) .sort((a, b) => a - b) .map(String), ); + failures += section( + 'opcodes published despite being withheld until channel_threads general availability', + [...CHANNEL_THREADS_UNTIL_GA.opcodes].filter((code) => docOpcodes.has(code)).map(String), + ); failures += section( 'close codes documented but absent from constants.erl', [...docCloseCodes] @@ -1735,17 +1821,19 @@ console.log('error registry'); } const problems: Array = []; for (const code of registryCodes) { - if (!documentedCodes.has(code)) { + if (!documentedCodes.has(code) && !CHANNEL_THREADS_UNTIL_GA.errorCodes.has(code)) { problems.push(`APIErrorCodes.${code} is not listed in the errors page registry`); } } const documentedRegistryCodes = [...registryCodes].filter((c) => documentedCodes.has(c)).length; + const withheldRegistryCodes = [...registryCodes].filter((c) => CHANNEL_THREADS_UNTIL_GA.errorCodes.has(c)).length; const UNDOCUMENTED_VALIDATION_CODES = new Set(['EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL']); - const expectedEntries = registryCodes.size + validationCodes.size - UNDOCUMENTED_VALIDATION_CODES.size; + const expectedEntries = + registryCodes.size + validationCodes.size - UNDOCUMENTED_VALIDATION_CODES.size - withheldRegistryCodes; if (documentedEntries < expectedEntries) { problems.push( - `errors.md code entries parsed fell to ${documentedEntries.toString()}, expected ${expectedEntries.toString()}, the ${registryCodes.size.toString()} API codes plus the ${validationCodes.size.toString()} validation codes less the ${UNDOCUMENTED_VALIDATION_CODES.size.toString()} named as undocumented. Either a code lost its entry, or the registry parser stopped matching one of the \`| CODE |\` table row and \`### \`CODE\`\` or \`#### \`CODE\`\` heading shapes it reads`, + `errors.md code entries parsed fell to ${documentedEntries.toString()}, expected ${expectedEntries.toString()}, the ${registryCodes.size.toString()} API codes plus the ${validationCodes.size.toString()} validation codes less the ${UNDOCUMENTED_VALIDATION_CODES.size.toString()} named as undocumented and the ${withheldRegistryCodes.toString()} withheld until channel_threads general availability. Either a code lost its entry, or the registry parser stopped matching one of the \`| CODE |\` table row and \`### \`CODE\`\` or \`#### \`CODE\`\` heading shapes it reads`, ); } for (const code of documentedCodes) { @@ -1753,11 +1841,19 @@ console.log('error registry'); problems.push(`errors.md documents ${code}, which is in neither code registry`); } } - if (documentedRegistryCodes < registryCodes.size) { + if (documentedRegistryCodes < registryCodes.size - withheldRegistryCodes) { problems.push( - `errors.md documents ${documentedRegistryCodes.toString()} of the ${registryCodes.size.toString()} registry codes, floor is every one of them`, + `errors.md documents ${documentedRegistryCodes.toString()} of the ${registryCodes.size.toString()} registry codes, floor is every one of them less the ${withheldRegistryCodes.toString()} withheld until channel_threads general availability`, ); } + for (const code of CHANNEL_THREADS_UNTIL_GA.errorCodes) { + if (!registryCodes.has(code)) { + problems.push(`CHANNEL_THREADS_UNTIL_GA withholds ${code}, which is not in the API code registry`); + } + if (documentedCodes.has(code)) { + problems.push(`errors.md publishes ${code}, which is withheld until channel_threads general availability`); + } + } console.log(` registry codes: ${registryCodes.size.toString()}, documented: ${documentedRegistryCodes.toString()}`); failures += section('error registry disagreements', problems); } @@ -1966,7 +2062,7 @@ console.log('bot capability flag (from the middleware chain)'); continue; } const anyLogin = route.hasLoginRequired; - const sourceAcceptsBot = anyLogin && !route.hasDefaultUserOnly; + const sourceAcceptsBot = (anyLogin || route.hasBotOnly) && !route.hasDefaultUserOnly; const exemption = BOT_EXEMPT.get(key); if (exemption != null) { if (documented.bot !== exemption.docs) { @@ -2082,10 +2178,11 @@ console.log('spec security field against the middleware chain'); const declaredSchemes = declaredSecurity.schemes; compared += 1; const anyLogin = route.hasLoginRequired; - const acceptsBot = anyLogin && !route.hasDefaultUserOnly; + const acceptsBot = (anyLogin || route.hasBotOnly) && !route.hasDefaultUserOnly; const requiresAuthentication = anyLogin || route.hasDefaultUserOnly || + route.hasBotOnly || route.oauth2BearerTokenRequired || route.middlewares.includes('requireOAuth2Scope'); if (requiresAuthentication && declaredSecurity.allowsAnonymous) { @@ -2104,7 +2201,15 @@ console.log('spec security field against the middleware chain'); if (!declaredSchemes.has('botToken') && acceptsBot && declaredSchemes.size > 0) { specBugs.push(`${key} omits botToken, but the middleware chain admits a bot token`); } - if (declaredSchemes.size > 0 && !anyLogin && !route.middlewares.some((name) => /Admin|OAuth2Scope/iu.test(name))) { + if (route.hasBotOnly && [...declaredSchemes].some((scheme) => scheme !== 'botToken')) { + specBugs.push(`${key} declares [${[...declaredSchemes].join(', ')}], but BotOnly admits only a bot token`); + } + if ( + declaredSchemes.size > 0 && + !anyLogin && + !route.hasBotOnly && + !route.middlewares.some((name) => /Admin|OAuth2Scope/iu.test(name)) + ) { specBugs.push(`${key} declares [${[...declaredSchemes].join(', ')}], but the chain has no login policy`); } } @@ -2124,7 +2229,10 @@ const adminTargetOnly = [...documentedAdmin.entries()] .map(([, route]) => `${route.method} ${route.path} (${route.file})`) .sort(); const adminLiveOnly = admin - .filter((operation) => !documentedAdmin.has(routeShape(operation.method, operation.path))) + .filter((operation) => { + const shape = routeShape(operation.method, operation.path); + return !documentedAdmin.has(shape) && !CHANNEL_THREADS_UNTIL_GA.routes.has(shape); + }) .map((operation) => `${operation.method} ${operation.path}`) .sort(); console.log(` live admin operations: ${admin.length.toString()}`); diff --git a/fluxer_docs/scripts/VerifyDocsSchemas.ts b/fluxer_docs/scripts/VerifyDocsSchemas.ts index 73c0af737..240692a35 100644 --- a/fluxer_docs/scripts/VerifyDocsSchemas.ts +++ b/fluxer_docs/scripts/VerifyDocsSchemas.ts @@ -16,6 +16,31 @@ const MAIN_SPEC = path.join(REPO_ROOT, 'fluxer_api/src/api/openapi/openapi.json' const ADMIN_SPEC = path.join(REPO_ROOT, 'fluxer_admin/openapi-admin.json'); const OBJECT_REFERENCE = /\]\([^)]*#[a-z0-9-]*object\)/u; +const CHANNEL_THREADS_FIELDS_UNTIL_GA = new Set([ + 'applied_tags', + 'available_tags', + 'channel_threads', + 'default_auto_archive_duration', + 'default_forum_layout', + 'default_reaction_emoji', + 'default_sort_order', + 'default_tag_setting', + 'default_thread_rate_limit_per_user', + 'flags', + 'member', + 'member_count', + 'member_ids_preview', + 'message_count', + 'message_send_cooldown_ms', + 'thread', + 'thread_create_cooldown_ms', + 'thread_id', + 'thread_metadata', + 'thread_name', + 'threads', + 'threads_active', + 'total_message_sent', +]); interface Mismatch { readonly page: string; @@ -408,6 +433,7 @@ let responsesChecked = 0; let responseFieldsFound = 0; let typesCompared = 0; let optionalityCompared = 0; +let withheldFields = 0; const optionalityAdvisories: Array = []; for (const page of pages) { @@ -527,6 +553,10 @@ for (const page of pages) { responseFieldsFound += 1; continue; } + if (CHANNEL_THREADS_FIELDS_UNTIL_GA.has(field)) { + withheldFields += 1; + continue; + } mismatches.push({page: relative, operation: key, kind: 'response-missing', detail: field}); } } @@ -608,6 +638,10 @@ for (const page of pages) { referencedElsewhere += 1; continue; } + if (CHANNEL_THREADS_FIELDS_UNTIL_GA.has(field)) { + withheldFields += 1; + continue; + } mismatches.push({page: relative, operation: key, kind: 'body-missing', detail: field}); } } @@ -631,6 +665,10 @@ for (const page of pages) { referencedElsewhere += 1; continue; } + if (CHANNEL_THREADS_FIELDS_UNTIL_GA.has(field)) { + withheldFields += 1; + continue; + } mismatches.push({page: relative, operation: key, kind: 'query-missing', detail: field}); } } @@ -670,6 +708,7 @@ console.log(`success response schemas checked: ${responsesChecked.toString()}`); console.log(`response fields found documented on the page: ${responseFieldsFound.toString()}`); console.log(`request and response field types compared: ${typesCompared.toString()}`); console.log(`request body optionality compared: ${optionalityCompared.toString()}`); +console.log(`channel_threads fields withheld until GA: ${withheldFields.toString()}`); console.log(`optionality advisories: ${optionalityAdvisories.length.toString()}`); if (optionalityAdvisories.length > 0) { for (const entry of optionalityAdvisories) { diff --git a/fluxer_docs/scripts/VerifyDocsSidebar.ts b/fluxer_docs/scripts/VerifyDocsSidebar.ts index 2b4335b17..d362dbd81 100644 --- a/fluxer_docs/scripts/VerifyDocsSidebar.ts +++ b/fluxer_docs/scripts/VerifyDocsSidebar.ts @@ -3,7 +3,7 @@ import {readFile} from 'node:fs/promises'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; -import {DOCS_ROOT, listMarkdownFiles, slugifyHeading} from './DocsSource.ts'; +import {DOCS_ROOT, listDraftMarkdownFiles, listMarkdownFiles, slugifyHeading} from './DocsSource.ts'; const ASTRO_CONFIG = fileURLToPath(new URL('../astro.config.ts', import.meta.url)); @@ -77,6 +77,7 @@ const orphanPages = [...actual].filter((slug) => !referenced.has(slug)).sort(); console.log(`sidebar entries: ${referenced.size.toString()}`); console.log(`content pages: ${actual.size.toString()}`); +console.log(`draft pages withheld from the build: ${(await listDraftMarkdownFiles(DOCS_ROOT)).length.toString()}`); let failures = 0; if (missingPages.length > 0) { diff --git a/fluxer_docs/scripts/VerifyDocsStyle.ts b/fluxer_docs/scripts/VerifyDocsStyle.ts index 0188a31ba..3681dc87a 100644 --- a/fluxer_docs/scripts/VerifyDocsStyle.ts +++ b/fluxer_docs/scripts/VerifyDocsStyle.ts @@ -66,7 +66,7 @@ function frontmatterOf(source: string): string | null { return source.slice(4, end); } -const pages = await readMarkdownPages(DOCS_ROOT); +const pages = await readMarkdownPages(DOCS_ROOT, {includeDrafts: true}); const findings: Array = []; for (const {file, relativePath: relative, source, lines} of pages) { @@ -288,6 +288,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map1 | boolean | Whether `enabled` was ever true | +| guild_basis_points | integer | Share of guilds the rollout selects, in basis points (0-10000, default 0) | +| guild_salt | string | Salt of the guild sampling hash (1-64 printable ASCII characters, default `channel-threads-guild-v1`) | +| enabled_guild_ids | array[snowflake] | Guilds the rollout always selects, up to 1000 entries (default empty) | +| disabled_guild_ids | array[snowflake] | Guilds the rollout never selects, up to 1000 entries (default empty) | +| user_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) | +| user_salt | string | Salt of the account sampling hash (1-64 printable ASCII characters, default `channel-threads-user-v1`) | +| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) | +| excluded_user_ids2 | array[snowflake] | Accounts and bots the rollout never selects, up to 1000 entries (default empty) | + +1 Set by Fluxer on the first write with `enabled` true, never cleared, and never accepted from a request + +2 An excluded moderation bot sees no thread and cannot act on one + +Every field is present on read. An absent document or missing field uses the defaults above. + +`disabled_guild_ids` wins over `enabled_guild_ids`, and `enabled_guild_ids` wins over `guild_basis_points`. The user fields follow the same order. A bot follows the guild fields and `excluded_user_ids` alone. A user also needs a client that declares `channel_threads`. + +A user outside the rollout in an enabled guild sees no thread, forum, or media channel while other members do. Set `user_basis_points` to 10000 before enabling a guild outside staff. + +:::caution[Disabling keeps the stored threads] +Setting `enabled` to false hides every thread, forum, and media channel and stops automatic archiving. Re-enabling shows the stored data again. +::: + +## List guild threads + + + +Returns every thread of a guild, active and archived, newest first. Requires `guild:lookup`. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| guild_id | snowflake | The ID of the guild | + +### Response body + +| Field | Type | Description | +| --- | --- | --- | +| threads | array[[thread](/http-api/threads/#thread-object) object] | The threads of the guild | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | response body | Threads were listed | + +### Side effects + +One [Admin audit entry](/admin-api/#admin-audit-entry-object) is recorded with the action `list_guild_threads`, the target type `guild`, and the guild ID as the target. Its metadata key `result_count` is the number of threads returned. + +### Rate limit + +200 requests per minute for each authenticated user, on the `admin:lookup` bucket. + +## Delete thread + + + +Deletes a thread with its messages and memberships. Requires `message:delete_all`. Returns 204 with no body. Emits a [Thread Delete](/gateway/events/#thread-delete) Gateway event. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| channel_id | snowflake | The ID of the thread | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 204 | None | Thread was deleted | +| 400 | [error response](/http-api/#error-response) | Channel is not a thread and the request returns `INVALID_CHANNEL_TYPE` | +| 404 | [error response](/http-api/#error-response) | Channel does not exist and the request returns `UNKNOWN_CHANNEL` | + +### Side effects + +One [Admin audit entry](/admin-api/#admin-audit-entry-object) is recorded with the action `delete_thread`, the target type `channel`, and the thread ID as the target. Its metadata is `guild_id`, `parent_id`, and `type`. The guild audit log records nothing. + +### Rate limit + +100 requests per minute for each authenticated user, on the `admin:message:operation` bucket. diff --git a/fluxer_docs/src/content/docs/gateway/threads.md b/fluxer_docs/src/content/docs/gateway/threads.md new file mode 100644 index 000000000..96122be5a --- /dev/null +++ b/fluxer_docs/src/content/docs/gateway/threads.md @@ -0,0 +1,170 @@ +--- +# SPDX-License-Identifier: AGPL-3.0-or-later +title: Gateway threads +description: Thread Dispatch events, thread subscriptions, forum unread counts, and the thread session flag. +draft: true +--- + +A session receives [threads](/http-api/threads/) through the Dispatch events below. A guild ready object in [Ready](/gateway/events/#ready), [Guild Create](/gateway/events/#guild-create), and [Guild Sync](/gateway/events/#guild-sync) has `threads` only when the session can view threads in that guild. A client treats the presence of that key as the signal that threads, forums, and media channels exist in the guild. + +:::note[Threads need the `channel_threads` experiment] +A user session receives thread data only when it sets `CHANNEL_THREADS` in Identify and the experiment is active for the guild and the account. A bot session needs no flag. +::: + +## Session flag + +| Value | Name | Description | +| --- | --- | --- | +| 1 << 2 | CHANNEL_THREADS | The client can parse thread, forum, and media channels | + +The flag joins the [session flags](/gateway/commands/#session-flags) of Identify. A user session without it sees no channel of type 11, 12, 15, or 16, and no thread event. + +## Guild threads + +`threads` on a guild ready object is an array of [thread objects](/http-api/threads/#thread-object). A user session receives the active threads it joined, each with `member`. A bot session receives every active thread it can view. + +The `member` of each entry has no `id`, `user_id`, or `member`. + +## Dispatch events + +| Event | Description | Scope | +| --- | --- | --- | +| [Thread Create](#thread-create) | A thread was created or became visible | Thread | +| [Thread Update](#thread-update) | A thread changed | Thread | +| [Thread Delete](#thread-delete) | A thread was deleted | Thread | +| [Thread List Sync](#thread-list-sync) | The session's active thread set was replaced | Guild | +| [Thread Member Update](#thread-member-update) | The session's own membership changed | Current user | +| [Thread Members Update](#thread-members-update) | Users joined or left a thread | Thread | +| [Thread Member List Update](#thread-member-list-update) | A subscribed thread member list resynced | Command response | +| [Forum Unreads](#forum-unreads) | Unread counts for forum posts | Command response | + +A session receives a thread event only when it can view the thread. Messages, reactions, typing, and pins in a thread reach a session that is a member of the thread, a bot session, or an active session that subscribed through `threads`. A message in a thread has `channel_type` set to the thread type. + +### THREAD_CREATE + +A thread was created, or the session was added to one. The payload is the [thread object](/http-api/threads/#thread-object) with the fields below. + +| Field | Type | Description | +| --- | --- | --- | +| newly_created? | boolean | Whether the thread was just created, present only then | +| member? | [thread member](/http-api/thread-members/#thread-member-object) object | The session's own membership, present only for a member | + +A user session receives it for a thread it joined, a thread it was added to, or any thread while subscribed through `threads`. A passive session receives it only when it was added. + +### THREAD_UPDATE + +A thread changed. The payload is the [thread object](/http-api/threads/#thread-object). + +A new message and a changed counter emit no Thread Update. A passive session receives it only for a thread it joined. + +### THREAD_DELETE + +A thread was deleted. + +| Field | Type | Description | +| --- | --- | --- | +| id | snowflake | The ID of the thread | +| guild_id | snowflake | The ID of the guild | +| parent_id | snowflake | The ID of the parent channel | +| type | integer | The [thread type](/http-api/threads/#thread-types) | + +Recipients are the sessions that could view the thread before the deletion. + +### THREAD_LIST_SYNC + +The session's active threads were replaced for a guild or for some of its channels. + +| Field | Type | Description | +| --- | --- | --- | +| guild_id | snowflake | The ID of the guild | +| channel_ids?1 | array[snowflake] | The parent channels whose threads are replaced | +| threads | array[[thread](/http-api/threads/#thread-object) object] | The active threads the session can view | +| members | array[[thread member](/http-api/thread-members/#thread-member-object) object] | The session's memberships in those threads | + +1 Absent when the event replaces every thread in the guild + +Fluxer sends it after `threads` is set to true in [Lazy Request](#lazy-request-thread-options), within 5,000 ms, and when the session gains access to a channel that has threads. + +### THREAD_MEMBER_UPDATE + +The session's own membership in a thread changed. The payload is the [thread member object](/http-api/thread-members/#thread-member-object) with `guild_id`. + +### THREAD_MEMBERS_UPDATE + +Users joined or left a thread. + +| Field | Type | Description | +| --- | --- | --- | +| id | snowflake | The ID of the thread | +| guild_id | snowflake | The ID of the guild | +| member_count | integer | The number of thread members, capped at 50 | +| added_members?1 | array[[thread member](/http-api/thread-members/#thread-member-object) object] | The users who joined | +| removed_member_ids? | array[snowflake] | The IDs of the users who left | + +1 Each entry adds `member`, the guild member, and `presence`, the [presence object](/gateway/events/#presence-object). Either is null when Fluxer has none + +A bot session receives every Thread Members Update it can view. A user session receives it when it was added or removed, is a member, or subscribed through `threads`. A passive session receives it only when it was added or removed. + +### THREAD_MEMBER_LIST_UPDATE + +A thread member list the session subscribed to through `thread_member_lists` resynced. + +| Field | Type | Description | +| --- | --- | --- | +| guild_id | snowflake | The ID of the guild | +| thread_id | snowflake | The ID of the thread | +| members1 | array[thread member list item object] | The members of the thread, at most 1,000 | + +1 Each item is `user_id`, `join_timestamp`, `flags`, `member`, and `presence`, where `member` is the guild member or null + +The event is delivered live and never retained for Resume. + +### FORUM_UNREADS + +The answer to [Request Forum Unreads](#request-forum-unreads). + +| Field | Type | Description | +| --- | --- | --- | +| guild_id | snowflake | The ID of the guild | +| channel_id | snowflake | The ID of the forum or media channel | +| threads | array[forum unread object] | One entry for each requested post the session can view | + +Each forum unread object is `thread_id` with either `count` or `missing`. `count` is the number of unread messages after the sent `ack_message_id`, capped at 25. `missing` is true when the request sent no `ack_message_id` for that post. + +## Lazy Request thread options + +The [guild subscription object](/gateway/commands/#guild-subscription-object) of [Lazy Request](/gateway/commands/#lazy-request) takes two more options. + +| Field | Type | Description | +| --- | --- | --- | +| threads? | boolean | Whether the session receives every thread event and thread message in the guild | +| thread_member_lists? | array[snowflake] | The threads whose member lists the session subscribes to, at most 10 | + +Fluxer ignores both options for a user session that did not set `CHANNEL_THREADS`. A thread the session cannot view is dropped from `thread_member_lists`. Each newly listed thread produces a [Thread Member List Update](#thread-member-list-update), and a membership change in it produces another. + +## Request Forum Unreads + +Opcode `28` requests unread counts for posts in one forum or media channel. + +| Field | Type | Description | +| --- | --- | --- | +| guild_id | snowflake | The ID of the guild | +| channel_id | snowflake | The ID of the forum or media channel | +| threads | array[forum unread request object] | The posts to count, deduplicated and truncated to 40 | + +Each forum unread request object is `thread_id` and an optional `ack_message_id`, the newest message the client has read in that post. + +```json +{ + "op": 28, + "d": { + "guild_id": "1189375284394692608", + "channel_id": "1189375284394692700", + "threads": [{"thread_id": "1189375284394692800", "ack_message_id": "1189375284394692900"}] + } +} +``` + +Results arrive in one [Forum Unreads](#forum-unreads), where a post outside the channel has no entry. A channel the session cannot view, and a request with no valid `thread_id`, produce no Dispatch. A session that cannot view threads closes with `4001` and reason `Unknown opcode`. + +A session sends at most five Request Forum Unreads commands in a rolling 5-second window. A further command is discarded without closing the connection. diff --git a/fluxer_docs/src/content/docs/http-api/forums.mdx b/fluxer_docs/src/content/docs/http-api/forums.mdx new file mode 100644 index 000000000..2b9ab46b1 --- /dev/null +++ b/fluxer_docs/src/content/docs/http-api/forums.mdx @@ -0,0 +1,280 @@ +--- +# SPDX-License-Identifier: AGPL-3.0-or-later +title: Forums +description: Forum and media channels, forum tags, post data, and webhooks that post into a forum. +draft: true +--- + +import RouteHeader from '@/components/RouteHeader.astro'; + +A forum channel holds posts, and every post is a public [thread](/http-api/threads/) with its own first message. A media channel is a forum channel whose posts center on attachments. Neither accepts a message of its own. + +:::note[Every route needs the `channel_threads` experiment] +A route on this page answers 404 `NOT_FOUND` unless the experiment is active for the guild and the caller. A user session also declares `channel_threads` in the `X-Fluxer-Features` header, and a bot needs no declaration. +::: + +## Forum channel fields + +A forum or media channel is a [channel object](/http-api/channels/#channel-object) of type `15` or `16` with the fields below. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| topic | ?string | The guidelines of the channel (1-4,096 characters) | +| last_message_id1 | ?snowflake | The ID of the newest post | +| flags | integer | The [channel flags](#channel-flags) of the channel | +| available_tags | array[[forum tag](#forum-tag-object) object] | The tags a post can apply (max 20) | +| default_reaction_emoji | ?[default reaction](#default-reaction-object) object | The reaction a client shows on each post, or null when none is set | +| default_sort_order | ?integer | The [sort order](#forum-sort-orders) a client uses, or null when none is set | +| default_forum_layout?2 | integer | The [layout](#forum-layouts) a client uses | +| default_tag_setting | string | The [tag matching](#forum-tag-settings) search uses by default | +| default_auto_archive_duration | ?integer | The [auto archive duration](/http-api/threads/#auto-archive-durations) new posts inherit, or null when none is set | +| default_thread_rate_limit_per_user | integer | The slowmode interval in seconds new posts inherit | + +1 [Channel Update](/gateway/events/#channel-update) is not emitted when this changes. A client advances it on [Thread Create](/gateway/events/#thread-create) + +2 Present only on a forum channel + +`rate_limit_per_user` on the channel limits how often one member creates a post. Sending [Create message](/http-api/messages/#create-message) to the channel itself returns 400 `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL`, and so do reactions and pins. + +### Example + +```json +{ + "id": "1501314428688998300", + "type": 15, + "guild_id": "1501314428688990000", + "name": "help", + "topic": "One question per post", + "position": 4, + "parent_id": null, + "permission_overwrites": [], + "last_message_id": "1501320000000000000", + "rate_limit_per_user": 0, + "flags": 16, + "available_tags": [ + {"id": "1501314428688998301", "name": "Solved", "moderated": true, "emoji_id": null, "emoji_name": "✅"} + ], + "default_reaction_emoji": null, + "default_sort_order": null, + "default_forum_layout": 0, + "default_tag_setting": "match_some", + "default_auto_archive_duration": null, + "default_thread_rate_limit_per_user": 0 +} +``` + +## Forum post object + +[Start thread](/http-api/threads/#start-thread) returns a forum post, which is a [thread object](/http-api/threads/#thread-object) with its first message. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| message | [message](/http-api/messages/#message-object) object | The first message of the post, which has the ID of the post | + +## Channel types + +| Value | Name | Description | +| --- | --- | --- | +| 15 | GUILD_FORUM | A guild channel whose messages live in posts | +| 16 | GUILD_MEDIA | A forum channel whose posts center on attachments | + +## Channel flags + +| Value | Name | Description | +| --- | --- | --- | +| 1 << 1 | PINNED | The post is pinned to the top of its forum | +| 1 << 4 | REQUIRE_TAG | A new post must apply at least one tag | +| 1 << 15 | HIDE_MEDIA_DOWNLOAD_OPTIONS | Clients hide download, open, and copy actions on images and videos in a media channel | + +`PINNED` is set on a post, and a forum holds at most one pinned post. `REQUIRE_TAG` and `HIDE_MEDIA_DOWNLOAD_OPTIONS` are set on the channel. `HIDE_MEDIA_DOWNLOAD_OPTIONS` on a forum channel returns 400 `HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY`, and any other bit returns 400 `INVALID_FORM_BODY`. + +## Forum tag object + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| id | snowflake | The ID of the tag | +| name | string | The name of the tag (1-50 characters) | +| moderated | boolean | Whether only thread moderators can apply or remove the tag | +| emoji_id | ?snowflake | The ID of a custom emoji of this guild, or null | +| emoji_name | ?string | A single Unicode emoji, or null | + +At most one of `emoji_id` and `emoji_name` is set. A tag name is unique within its channel, and the comparison is case-sensitive. + +## Default reaction object + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| emoji_id | ?snowflake | The ID of a custom emoji of this guild, or null | +| emoji_name | ?string | A single Unicode emoji, or null | + +## Forum sort orders + +| Value | Name | Description | +| --- | --- | --- | +| 0 | LATEST_ACTIVITY | Sort posts by their newest message | +| 1 | CREATION_TIME | Sort posts by creation time | + +## Forum layouts + +| Value | Name | Description | +| --- | --- | --- | +| 0 | DEFAULT | No layout is set | +| 1 | LIST | Show posts as a list | +| 2 | GRID | Show posts as a grid of tiles | + +## Forum tag settings + +| Value | Description | +| --- | --- | +| match_some | A post matches when it applies any of the searched tags | +| match_all | A post matches when it applies every searched tag | + +## Creating and modifying a forum + +[Create guild channel](/http-api/guild-channels/#create-guild-channel) with `type` `15` or `16`, and [Modify channel](/http-api/channels/#modify-channel) on a forum or media channel, accept these fields beside the common guild channel fields. + +| Field | Type | Description | +| --- | --- | --- | +| topic? | ?string | The guidelines of the channel (1-4,096 characters) | +| available_tags?1 | array[[forum tag](#forum-tag-object) object] | The complete tag set (max 20) | +| default_reaction_emoji? | ?[default reaction](#default-reaction-object) object | The default reaction, or null to clear it | +| default_sort_order? | ?integer | The [sort order](#forum-sort-orders), or null to clear it | +| default_forum_layout?2 | integer | The [layout](#forum-layouts) | +| default_tag_setting? | ?string | The [tag matching](#forum-tag-settings) default | +| default_auto_archive_duration? | ?integer | The [auto archive duration](/http-api/threads/#auto-archive-durations) new posts inherit, or null to clear it | +| default_thread_rate_limit_per_user? | integer | The slowmode interval in seconds new posts inherit (0-21,600) | +| flags? | integer | The [channel flags](#channel-flags) of the channel | + +1 An entry with `id` keeps that tag, and an entry without one creates a tag. A stored tag missing from the array is deleted + +2 Read only for a forum channel + +A guild text channel accepts `default_auto_archive_duration` and `default_thread_rate_limit_per_user` as well. + +A tag set over 20 returns 400 `MAX_FORUM_TAGS`, and a repeated name returns 400 `FORUM_TAG_NAMES_MUST_BE_UNIQUE`. An `id` the channel does not hold returns 404 `UNKNOWN_FORUM_TAG`. An `emoji_id` from another guild returns 404 `UNKNOWN_EMOJI`. + +`REQUIRE_TAG` on a channel whose every tag is moderated returns 400 `NO_TAGS_AVAILABLE_TO_NON_MODERATORS`. + +An invite to a forum or media channel returns 400 `INVALID_CHANNEL_TYPE`. + +## Create forum tag + + + +Adds a tag to a forum or media channel and returns the updated [channel object](/http-api/channels/#channel-object). Requires `MANAGE_CHANNELS`. Emits a [Channel Update](/gateway/events/#channel-update) Gateway event. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| channel_id | snowflake | The ID of the forum or media channel | + +### JSON body + +| Field | Type | Description | +| --- | --- | --- | +| name | string | The name of the tag (1-50 characters) | +| moderated? | boolean | Whether only thread moderators can apply or remove the tag (default false) | +| emoji_id? | ?snowflake | The ID of a custom emoji of this guild | +| emoji_name? | ?string | A single Unicode emoji (max 64 characters) | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | [channel](/http-api/channels/#channel-object) object | Tag was added | +| 400 | [error response](/http-api/#error-response) | Channel is not a forum or media channel and the request returns `INVALID_CHANNEL_TYPE` | +| 400 | [error response](/http-api/#error-response) | Channel has 20 tags and the request returns `MAX_FORUM_TAGS` | +| 400 | [error response](/http-api/#error-response) | Channel has a tag with the name and the request returns `FORUM_TAG_NAMES_MUST_BE_UNIQUE` | +| 403 | [error response](/http-api/#error-response) | Caller lacks `MANAGE_CHANNELS` and the request returns `MISSING_PERMISSIONS` | + +### Rate limit + +10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:forum_tags::channel_id` bucket. + +## Modify forum tag + + + +Replaces a tag of a forum or media channel and returns the updated [channel object](/http-api/channels/#channel-object). Requires `MANAGE_CHANNELS`. Emits a [Channel Update](/gateway/events/#channel-update) Gateway event. + +The body matches [Create forum tag](#create-forum-tag). A `tag_id` the channel does not hold returns 404 `UNKNOWN_FORUM_TAG`. + +### Rate limit + +10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:forum_tags::channel_id` bucket. + +## Delete forum tag + + + +Removes a tag from a forum or media channel and returns the updated [channel object](/http-api/channels/#channel-object). Requires `MANAGE_CHANNELS`. Emits a [Channel Update](/gateway/events/#channel-update) Gateway event. + +A post that applied the tag keeps the ID until its tags next change, and a client ignores an ID absent from `available_tags`. + +### Rate limit + +10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:forum_tags::channel_id` bucket. + +## Get forum post data + + + +Returns the owner and first message of each requested post in a forum or media channel. Requires `READ_MESSAGE_HISTORY`. + +### JSON body + +| Field | Type | Description | +| --- | --- | --- | +| thread_ids | array[snowflake] | The IDs of the posts (1-100) | + +### Response body + +| Field | Type | Description | +| --- | --- | --- | +| threads1 | map[snowflake, [post data](#post-data-object) object] | The post data keyed by post ID | + +1 An ID that is not a post of this channel is left out + +#### Post data object + +| Field | Type | Description | +| --- | --- | --- | +| owner | ?[guild member](/http-api/guild-members/#guild-member-object) object | The guild member who created the post, or null when they left the guild | +| first_message | ?[message](/http-api/messages/#message-object) object | The first message of the post, or null when it was deleted | + +### Rate limit + +20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:post_data::channel_id` bucket. + +## Webhooks in forums + +A webhook whose channel is a forum or media channel creates a post, or posts into an existing one. [Execute webhook](/http-api/webhooks/#execute-webhook) takes the fields below. + +| Field | Type | Description | +| --- | --- | --- | +| thread_id?1 | snowflake | The query parameter naming the thread to post into | +| thread_name? | string | The name of a new post (1-100 characters) | +| applied_tags?2 | array[snowflake] | The IDs of the tags a new post applies (max 5) | + +1 The thread must belong to the channel of the webhook. Any other thread returns 404 `UNKNOWN_CHANNEL` + +2 A moderated tag returns 403 `MISSING_PERMISSIONS` + +`thread_id` also works on a webhook whose channel is a guild text channel, and on the webhook message routes. A forum execution without `thread_id` or `thread_name` returns 400 `WEBHOOK_FORUM_TARGET_REQUIRED`, and one with both returns 400 `WEBHOOK_FORUM_TARGET_CONFLICT`. + +`thread_name` or `applied_tags` on any other channel returns 400 `WEBHOOK_THREAD_NAME_REQUIRES_FORUM`. The Slack and GitHub formats need `thread_id` on a forum, and 400 `WEBHOOK_SERVICE_FORUM_UNSUPPORTED` answers without one. + +A locked thread refuses a webhook message with 400 `THREAD_LOCKED`. An archived thread that is not locked is unarchived by it. + +## New post notifications + +A [channel notification override](/http-api/users/settings/#channel-notification-override-object) on a forum or media channel has `flags`. `NEW_FORUM_THREADS_OFF` (1 << 13) and `NEW_FORUM_THREADS_ON` (1 << 14) turn notifications for new posts off or on for the caller. diff --git a/fluxer_docs/src/content/docs/http-api/thread-members.mdx b/fluxer_docs/src/content/docs/http-api/thread-members.mdx new file mode 100644 index 000000000..7692900b4 --- /dev/null +++ b/fluxer_docs/src/content/docs/http-api/thread-members.mdx @@ -0,0 +1,270 @@ +--- +# SPDX-License-Identifier: AGPL-3.0-or-later +title: Thread members +description: Thread member objects, joining and leaving threads, member lists, and thread notification settings. +draft: true +--- + +import RouteHeader from '@/components/RouteHeader.astro'; + +A thread member records that a user joined a [thread](/http-api/threads/). A thread holds at most 1,000 members, and adding one more returns 400 `MAX_THREAD_MEMBERS`. + +:::note[Every route needs the `channel_threads` experiment] +A route on this page answers 404 `NOT_FOUND` unless the experiment is active for the guild and the caller. A user session also declares `channel_threads` in the `X-Fluxer-Features` header, and a bot needs no declaration. +::: + +## Thread member object + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| id?1 | snowflake | The ID of the thread | +| user_id?1 | snowflake | The ID of the user | +| join_timestamp | ISO8601 timestamp | The time the user last joined the thread | +| flags | integer | The [thread member flags](#thread-member-flags) | +| muted?2 | boolean | Whether the user muted the thread | +| mute_config?2 | ?[guild mute configuration](/http-api/users/settings/#guild-mute-configuration-object) object | The mute expiry of the thread, or null when none is stored | +| member?3 | [guild member](/http-api/guild-members/#guild-member-object) object | The guild member of the user | + +1 Omitted on the `member` of a thread in [Ready](/gateway/events/#ready) and [Guild Create](/gateway/events/#guild-create) + +2 Present only on the caller's own membership + +3 Present only when the request sets `with_member` + +### Example + +```json +{ + "id": "1501320000000000000", + "user_id": "1501314428688992222", + "join_timestamp": "2026-09-28T09:14:02.000Z", + "flags": 3, + "muted": false, + "mute_config": null +} +``` + +## Thread member flags + +| Value | Name | Description | +| --- | --- | --- | +| 1 << 0 | HAS_INTERACTED1 | The user sent a message in the thread or created it | +| 1 << 1 | ALL_MESSAGES | Notify the user of every message in the thread | +| 1 << 2 | ONLY_MENTIONS | Notify the user only of mentions in the thread | +| 1 << 3 | NO_MESSAGES | Notify the user of nothing in the thread | + +1 Set only by Fluxer + +At most one of the three notification flags is set. With none set, the thread follows the notification level of its parent. + +A member keeps the membership after losing access to the parent. Leaving the guild removes every membership in its threads. + +## Join thread + + + +Adds the caller to a thread. Returns 204 with no body. Emits a [Thread Members Update](/gateway/events/#thread-members-update) Gateway event. + +### Limitations + +- The caller must be able to view the thread. +- The thread must not be archived. +- A locked thread requires a thread moderator. + +Joining a thread the caller already belongs to returns 204 and changes nothing. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| channel_id | snowflake | The ID of the thread | + +### Query parameters + +| Field | Type | Description | +| --- | --- | --- | +| location? | string | A value Fluxer accepts and ignores (max 100 characters) | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 204 | None | Caller is a member | +| 400 | [error response](/http-api/#error-response) | Channel is not a thread and the request returns `INVALID_CHANNEL_TYPE` | +| 400 | [error response](/http-api/#error-response) | Thread is archived and the request returns `THREAD_ARCHIVED`, or locked and the request returns `THREAD_LOCKED` | +| 400 | [error response](/http-api/#error-response) | Thread has 1,000 members and the request returns `MAX_THREAD_MEMBERS` | +| 403 | [error response](/http-api/#error-response) | Caller cannot view the thread and the request returns `MISSING_ACCESS` | + +### Rate limit + +10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:thread:member:put::channel_id` bucket. + +## Add thread member + + + +Adds another guild member to a thread. Requires `SEND_MESSAGES_IN_THREADS`. Returns 204 with no body. Emits a [Thread Members Update](/gateway/events/#thread-members-update) Gateway event. + +### Limitations + +- The thread must not be archived. +- A locked thread requires a thread moderator. +- A private thread with `invitable` false requires the caller or the target to be a thread moderator. +- The target must be a guild member who can view the parent. + +`user_id` of `@me` behaves as [Join thread](#join-thread). + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| channel_id | snowflake | The ID of the thread | +| user_id | snowflake | The ID of the user to add | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 204 | None | Target is a member | +| 400 | [error response](/http-api/#error-response) | Thread is archived and the request returns `THREAD_ARCHIVED`, or locked and the request returns `THREAD_LOCKED` | +| 400 | [error response](/http-api/#error-response) | Thread has 1,000 members and the request returns `MAX_THREAD_MEMBERS` | +| 403 | [error response](/http-api/#error-response) | Caller lacks `SEND_MESSAGES_IN_THREADS`, or the thread is not `invitable`, each returning `MISSING_PERMISSIONS` | +| 403 | [error response](/http-api/#error-response) | Target cannot view the parent and the request returns `MISSING_ACCESS` | +| 403 | [error response](/http-api/#error-response) | Caller is timed out and the request returns `COMMUNICATION_DISABLED` | +| 404 | [error response](/http-api/#error-response) | Target is not a guild member and the request returns `UNKNOWN_MEMBER` | + +### Side effects + +Adding another user posts a `RECIPIENT_ADD` message in the thread that mentions the target. + +### Rate limit + +10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:thread:member:put::channel_id` bucket. + +## Leave thread + + + +Removes the caller from a thread. Returns 204 with no body. Emits a [Thread Members Update](/gateway/events/#thread-members-update) Gateway event. + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 204 | None | Caller left the thread | +| 400 | [error response](/http-api/#error-response) | Thread is archived and the request returns `THREAD_ARCHIVED` | +| 404 | [error response](/http-api/#error-response) | Caller is not a member and the request returns `UNKNOWN_THREAD_MEMBER` | + +### Rate limit + +10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:thread:member:delete::channel_id` bucket. + +## Remove thread member + + + +Removes a member from a thread. Requires `MANAGE_THREADS`, or ownership of a private thread. Returns 204 with no body. Emits a [Thread Members Update](/gateway/events/#thread-members-update) Gateway event. + +`user_id` of `@me` or of the caller behaves as [Leave thread](#leave-thread). + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 204 | None | Member was removed | +| 400 | [error response](/http-api/#error-response) | Thread is archived and the request returns `THREAD_ARCHIVED` | +| 403 | [error response](/http-api/#error-response) | Caller is neither a thread moderator nor the owner of the private thread and the request returns `MISSING_PERMISSIONS` | +| 403 | [error response](/http-api/#error-response) | Caller is timed out and the request returns `COMMUNICATION_DISABLED` | +| 404 | [error response](/http-api/#error-response) | Target is not a member and the request returns `UNKNOWN_THREAD_MEMBER` | + +### Side effects + +Removing another user posts a `RECIPIENT_REMOVE` message in the thread that mentions the target. + +### Rate limit + +10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:thread:member:delete::channel_id` bucket. + +## Get thread member + + + +Returns the [thread member object](#thread-member-object) of a user. Bot-only. + +### Query parameters + +| Field | Type | Description | +| --- | --- | --- | +| with_member? | boolean | Whether to include the guild member of the user (default false) | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | [thread member](#thread-member-object) object | Member was returned | +| 404 | [error response](/http-api/#error-response) | User is not a member and the request returns `UNKNOWN_THREAD_MEMBER` | +| 404 | [error response](/http-api/#error-response) | Caller is a user and the request returns `NOT_FOUND` | + +### Rate limit + +40 requests per 10 seconds for each authenticated user and channel ID, on the `channel:thread:member:get::channel_id` bucket. + +## List thread members + + + +Returns the [thread member objects](#thread-member-object) of a thread in ascending user ID order. Bot-only. + +A user client reads member lists through the `thread_member_lists` option of [Lazy Request](/gateway/commands/#lazy-request). + +### Query parameters + +| Field | Type | Description | +| --- | --- | --- | +| with_member? | boolean | Whether to include the guild member of each thread member (default false) | +| after? | snowflake | Return members with a user ID above this one | +| limit? | integer | The maximum number of members to return (1-100, default 100) | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | array[[thread member](#thread-member-object) object] | Members were returned | +| 404 | [error response](/http-api/#error-response) | Caller is a user and the request returns `NOT_FOUND` | + +### Rate limit + +20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:thread:members:list::channel_id` bucket. + +## Modify thread member settings + + + +Changes the caller's notification settings for a thread they joined. User-only. Returns the updated [thread member object](#thread-member-object), or 204 when nothing changed. Emits a [Thread Member Update](/gateway/events/#thread-member-update) Gateway event. + +### JSON body + +| Field | Type | Description | +| --- | --- | --- | +| flags?1 | integer | The notification [thread member flags](#thread-member-flags) | +| muted? | boolean | Whether the thread is muted | +| mute_config? | ?[guild mute configuration input](/http-api/users/settings/#guild-mute-configuration-input-object) object | The mute expiry of the thread, or null to clear it | + +1 Replaces the three notification flags and keeps `HAS_INTERACTED` + +A muted thread sends no push notification, including for a mention. + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | [thread member](#thread-member-object) object | Settings changed | +| 204 | None | The body matched the stored settings | +| 400 | [error response](/http-api/#error-response) | `flags` sets more than one notification flag or any other bit and the request returns `INVALID_THREAD_NOTIFICATION_SETTINGS` | +| 404 | [error response](/http-api/#error-response) | Caller is not a member and the request returns `UNKNOWN_THREAD_MEMBER` | + +### Rate limit + +10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:thread:member:settings::channel_id` bucket. diff --git a/fluxer_docs/src/content/docs/http-api/threads.mdx b/fluxer_docs/src/content/docs/http-api/threads.mdx new file mode 100644 index 000000000..a75e07ecf --- /dev/null +++ b/fluxer_docs/src/content/docs/http-api/threads.mdx @@ -0,0 +1,622 @@ +--- +# SPDX-License-Identifier: AGPL-3.0-or-later +title: Threads +description: Thread objects, starting and archiving threads, archived thread lists, thread search, and thread permissions. +draft: true +--- + +import RouteHeader from '@/components/RouteHeader.astro'; + +A thread is a channel inside a guild text, announcement, forum, or media channel that holds its own messages. A public thread or an announcement thread is visible to everyone who can view its parent. A private thread is visible to its members and to thread moderators. + +[Thread members](/http-api/thread-members/) defines joining, leaving, and member lists. [Forums](/http-api/forums/) defines forum and media channels, where every thread is a post. + +:::note[Every route needs the `channel_threads` experiment] +A route on this page answers 404 `NOT_FOUND` unless the experiment is active for the guild and the caller. A user session also declares `channel_threads` in the `X-Fluxer-Features` header, and a bot needs no declaration. +::: + +## Thread object + +A thread is a [channel object](/http-api/channels/#channel-object) with the thread fields below. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| id1 | snowflake | The ID of the thread | +| type | integer | The [thread type](#thread-types) | +| guild_id | snowflake | The ID of the guild | +| parent_id | snowflake | The ID of the text, forum, or media channel the thread belongs to | +| owner_id | snowflake | The ID of the user or webhook that created the thread | +| name | string | The name of the thread (1-100 characters) | +| last_message_id | ?snowflake | The ID of the most recent message, or null when the thread has none | +| last_pin_timestamp | ?ISO8601 timestamp | The time a message was most recently pinned, or null when nothing has ever been pinned | +| rate_limit_per_user | integer | The slowmode interval in seconds (0-21,600) | +| flags2 | integer | The [channel flags](/http-api/forums/#channel-flags) of the thread | +| thread_metadata | [thread metadata](#thread-metadata-object) object | The archive and lock state of the thread | +| message_count3 | integer | The number of messages in the thread | +| total_message_sent4 | integer | The number of messages ever sent in the thread | +| member_count5 | integer | The number of thread members, capped at 50 | +| applied_tags?6 | array[snowflake] | The IDs of the [forum tags](/http-api/forums/#forum-tag-object) applied to a post (max 5) | +| member_ids_preview?6 | array[snowflake] | The IDs of the most recently joined members of a post, newest first (max 8) | +| member?7 | [thread member](/http-api/thread-members/#thread-member-object) object | The thread member object of the caller | + +1 A thread started from a message has the ID of that message. A forum post has the ID of its first message + +2 Only `PINNED` is ever set, and only on a forum or media post + +3 Excludes the first message and every deleted message + +4 Includes deleted messages and never decreases + +5 The value stays at 50 when the thread has more members + +6 Present only on a thread whose parent is a forum or media channel + +7 Present only on [Get channel](/http-api/channels/#get-channel), on the `threads` list of a guild in [Ready](/gateway/events/#ready) and [Guild Create](/gateway/events/#guild-create), and on `thread` of a message, each when the caller is a member + +A thread has no `position`, no `permission_overwrites`, and no `nsfw` fields. It uses the permissions of its parent as [Thread permissions](#thread-permissions) describes. An age restriction and a content warning resolve through the parent, then the parent category, and finally the guild. + +### Example + +```json +{ + "id": "1501320000000000000", + "type": 11, + "guild_id": "1501314428688990000", + "parent_id": "1501314428688998182", + "owner_id": "1501314428688992222", + "name": "Release checklist", + "last_message_id": "1501320500000000000", + "last_pin_timestamp": null, + "rate_limit_per_user": 0, + "flags": 0, + "thread_metadata": { + "archived": false, + "auto_archive_duration": 4320, + "archive_timestamp": "2026-09-28T09:14:02.000Z", + "locked": false, + "create_timestamp": "2026-09-28T09:14:02.000Z" + }, + "message_count": 12, + "total_message_sent": 14, + "member_count": 3 +} +``` + +## Thread metadata object + +The archive and lock state of a thread. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| archived | boolean | Whether the thread is archived | +| auto_archive_duration | integer | The [auto archive duration](#auto-archive-durations) in minutes | +| archive_timestamp1 | ISO8601 timestamp | The time the archive state last changed | +| locked | boolean | Whether only thread moderators can act in the thread | +| invitable?2 | boolean | Whether a member who is not a thread moderator can add other members | +| create_timestamp | ISO8601 timestamp | The time the thread was created | + +1 Equal to `create_timestamp` until the thread is first archived or its duration changes + +2 Present only on a private thread + +## Thread types + +| Value | Name | Description | +| --- | --- | --- | +| 10 | ANNOUNCEMENT_THREAD | A thread in an announcement channel, visible to everyone who can view the parent | +| 11 | PUBLIC_THREAD | A thread visible to everyone who can view the parent | +| 12 | PRIVATE_THREAD | A thread in a guild text channel visible to its members and to thread moderators | + +A forum or media post is always `PUBLIC_THREAD`. Every thread in an announcement channel is `ANNOUNCEMENT_THREAD`, and an announcement channel holds no private threads. + +Converting a guild text channel into an announcement channel turns each of its public threads into an announcement thread, and converting it back turns them into public threads again. Each active thread emits [Thread Update](/gateway/events/#thread-update) with the new `type`. A guild text channel with a private thread cannot become an announcement channel, and the request returns 400 `CHANNEL_HAS_THREADS`. + +A message in an announcement thread cannot be published to following channels, and a follow never delivers it. Publishing the message a thread started from works as for any other announcement, and the copies carry neither `HAS_THREAD` nor `thread`. + +## Auto archive durations + +| Value | Description | +| --- | --- | +| 60 | One hour | +| 1440 | One day | +| 4320 | Three days, the default | +| 10080 | One week | + +A thread inherits the `default_auto_archive_duration` of its parent when the create request omits the field, and uses 4320 when the parent has none. + +## Thread lifecycle + +Fluxer archives an active thread once no message has arrived for its `auto_archive_duration`. A pinned forum post is never archived automatically. Archiving a pinned post clears its `PINNED` flag. + +A guild holds at most 1,000 active threads. Creating or unarchiving a thread past that count returns 400 `MAX_ACTIVE_THREADS`. + +Sending a message to an archived thread that is not locked unarchives it. Fluxer emits [Thread Update](/gateway/events/#thread-update) before the message. A locked thread accepts messages, reactions, and member changes only from a thread moderator. + +An archived thread accepts no reaction, pin, edit, join, leave, or member change, and each returns 400 `THREAD_ARCHIVED`. Deleting a message in an archived thread is allowed. + +### Thread moderators + +A thread moderator is the guild owner, a member with `ADMINISTRATOR`, or a member holding `MANAGE_THREADS` in the parent who is not timed out. + +## Thread permissions + +A thread has no permission overwrites of its own. Fluxer computes permissions in the parent channel and then replaces `SEND_MESSAGES` with `SEND_MESSAGES_IN_THREADS`. Posting in a forum or media channel itself uses the unreplaced `SEND_MESSAGES` of that channel. + +| Value | Name | Description | +| --- | --- | --- | +| 1 << 34 | MANAGE_THREADS1 | Archive, lock, rename, and delete a thread, and act in a locked thread | +| 1 << 35 | CREATE_PUBLIC_THREADS | Start a public thread in a guild text channel | +| 1 << 36 | CREATE_PRIVATE_THREADS | Start a private thread in a guild text channel | +| 1 << 38 | SEND_MESSAGES_IN_THREADS | Send a message in a thread, and add another member to a thread | + +1 An [elevated permission](/http-api/permissions/#elevated-permissions). The thread owner and member alternatives below are checked first, so a thread owner acting on their own thread needs no authenticator + +`ADMINISTRATOR` grants all four bits. A role or overwrite write keeps the stored value of the four bits unless the caller can view threads in the guild. + +A timed out member can read a thread and can join or leave one. Every other thread write from a timed out member who is neither the guild owner nor an administrator returns 403 `COMMUNICATION_DISABLED`. + +A caller who can view the parent but is neither a member nor a thread moderator receives 403 `MISSING_ACCESS` for a private thread. Losing access to the parent keeps the membership, and the member receives no event from the thread until access returns. + +## Start thread from message + + + +Starts a public thread from a message in a guild text channel, or an announcement thread from a message in an announcement channel. Requires `CREATE_PUBLIC_THREADS` and `READ_MESSAGE_HISTORY`. Returns the [thread object](#thread-object), which extends the [channel object](/http-api/channels/#channel-object). Emits a [Thread Create](/gateway/events/#thread-create) Gateway event. + +### Limitations + +- The channel must be a guild text or announcement channel. +- The message must be a default message or a reply. +- A message starts at most one thread. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| channel_id | snowflake | The ID of the guild text or announcement channel | +| message_id | snowflake | The ID of the message to start the thread from | + +### JSON body + +| Field | Type | Description | +| --- | --- | --- | +| name | string | The name of the thread (1-100 characters) | +| auto_archive_duration? | integer | The [auto archive duration](#auto-archive-durations) in minutes | +| rate_limit_per_user? | integer | The slowmode interval in seconds (0-21,600) | +| location? | string | A value Fluxer accepts and ignores (max 100 characters) | + +`rate_limit_per_user` defaults to the `default_thread_rate_limit_per_user` of the parent, or `0` when the parent has none. + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 201 | [thread](#thread-object) object | Thread was started | +| 400 | [error response](/http-api/#error-response) | Channel is not a guild text or announcement channel and the request returns `INVALID_CHANNEL_TYPE` | +| 400 | [error response](/http-api/#error-response) | Message is a system message and the request returns `CANNOT_MODIFY_SYSTEM_WEBHOOK` | +| 400 | [error response](/http-api/#error-response) | Message already started a thread and the request returns `THREAD_ALREADY_CREATED_FOR_MESSAGE` | +| 400 | [error response](/http-api/#error-response) | Guild has 1,000 active threads and the request returns `MAX_ACTIVE_THREADS` | +| 400 | [error response](/http-api/#error-response) | Parent slowmode denies the thread and the request returns `SLOWMODE_RATE_LIMITED` | +| 403 | [error response](/http-api/#error-response) | Caller lacks a required permission and the request returns `MISSING_PERMISSIONS` | +| 403 | [error response](/http-api/#error-response) | Caller is timed out and the request returns `COMMUNICATION_DISABLED` | +| 404 | [error response](/http-api/#error-response) | Channel does not exist and the request returns `UNKNOWN_CHANNEL` | +| 404 | [error response](/http-api/#error-response) | Message does not exist and the request returns `UNKNOWN_MESSAGE` | + +### Side effects + +- Fluxer sets `HAS_THREAD` on the message and emits [Message Update](/gateway/events/#message-update) with `thread`. +- The thread opens with a `THREAD_STARTER_MESSAGE`, which has the thread ID and references the source message. +- A source message at least five minutes old also produces a `THREAD_CREATED` message in the parent. +- The caller becomes the first thread member. + +Starting a thread uses the parent slowmode on its own counter. A bot and a member holding `BYPASS_SLOWMODE` are exempt. + +### Rate limit + +10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:thread:create::channel_id` bucket. + +## Start thread + + + +Starts a thread with no source message, or creates a post in a forum or media channel. Returns the [thread object](#thread-object), which extends the [channel object](/http-api/channels/#channel-object). Emits a [Thread Create](/gateway/events/#thread-create) Gateway event. + +### Limitations + +- In a guild text channel, a public thread requires `CREATE_PUBLIC_THREADS` and a private thread requires `CREATE_PRIVATE_THREADS`. +- In an announcement channel, a thread requires `CREATE_PUBLIC_THREADS`. Both `ANNOUNCEMENT_THREAD` and `PUBLIC_THREAD` start an announcement thread, and `PRIVATE_THREAD` returns 400 `INVALID_CHANNEL_TYPE`. +- In a guild text channel, `ANNOUNCEMENT_THREAD` returns 400 `INVALID_CHANNEL_TYPE`. +- In a forum or media channel, a post requires `SEND_MESSAGES` in that channel. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| channel_id | snowflake | The ID of the guild text, announcement, forum, or media channel | + +### JSON body + +The parent type selects the body. A forum or media channel also accepts multipart form data with the JSON in a `payload_json` part and files in `files[n]` parts, as [Create message](/http-api/messages/#create-message) describes. + +#### Text and announcement channel body + +| Field | Type | Description | +| --- | --- | --- | +| name | string | The name of the thread (1-100 characters) | +| type | integer | The [thread type](#thread-types) to start | +| auto_archive_duration? | integer | The [auto archive duration](#auto-archive-durations) in minutes | +| rate_limit_per_user? | integer | The slowmode interval in seconds (0-21,600) | +| invitable?1 | boolean | Whether a member who is not a thread moderator can add other members (default true) | +| location? | string | A value Fluxer accepts and ignores (max 100 characters) | + +1 Read only for a private thread + +#### Forum post body + +| Field | Type | Description | +| --- | --- | --- | +| name | string | The name of the post (1-100 characters) | +| message | object | The first message, with the fields of [Create message](/http-api/messages/#create-message) other than `message_reference`, `nonce`, `tts`, and `favorite_meme_id` | +| applied_tags?1 | array[snowflake] | The IDs of the forum tags to apply (max 5) | +| auto_archive_duration? | integer | The [auto archive duration](#auto-archive-durations) in minutes | +| rate_limit_per_user? | integer | The slowmode interval in seconds (0-21,600) | +| type?2 | integer | The thread type, which is always `11` | +| location? | string | A value Fluxer accepts and ignores (max 100 characters) | + +1 Required when the channel has `REQUIRE_TAG`. A tag marked `moderated` needs a thread moderator + +2 Any other value returns 400 `INVALID_CHANNEL_TYPE` + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 201 | [forum post](/http-api/forums/#forum-post-object) object | Post was created in a forum or media channel | +| 201 | [thread](#thread-object) object | Thread was started in a guild text or announcement channel | +| 400 | [error response](/http-api/#error-response) | Channel cannot hold threads, or the thread type does not fit it, returning `INVALID_CHANNEL_TYPE` | +| 400 | [error response](/http-api/#error-response) | Guild has 1,000 active threads and the request returns `MAX_ACTIVE_THREADS` | +| 400 | [error response](/http-api/#error-response) | Parent slowmode denies the thread and the request returns `SLOWMODE_RATE_LIMITED` | +| 400 | [error response](/http-api/#error-response) | A forum post names no tag under `REQUIRE_TAG` and the request returns `FORUM_TAG_REQUIRED` | +| 400 | [error response](/http-api/#error-response) | As above while every tag is moderated, returning `NO_TAGS_AVAILABLE_TO_NON_MODERATORS` | +| 403 | [error response](/http-api/#error-response) | Caller lacks a required permission or applies a moderated tag, returning `MISSING_PERMISSIONS` | +| 403 | [error response](/http-api/#error-response) | Caller is timed out and the request returns `COMMUNICATION_DISABLED` | +| 404 | [error response](/http-api/#error-response) | Channel does not exist and the request returns `UNKNOWN_CHANNEL` | +| 404 | [error response](/http-api/#error-response) | A tag does not exist and the request returns `UNKNOWN_FORUM_TAG` | + +### Side effects + +- A public thread in a guild text channel and an announcement thread post a `THREAD_CREATED` message in the parent. A private thread posts none. +- The first message of a forum post has the ID of the post, and a failed first message deletes the post again. +- Creating a forum post marks the forum as read for the caller. +- The caller becomes the first thread member. + +### Rate limit + +10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:thread:create::channel_id` bucket. + +## Modify thread + +[Modify channel](/http-api/channels/#modify-channel) on a thread takes the thread body below and returns the updated [thread object](#thread-object). It emits a [Thread Update](/gateway/events/#thread-update) Gateway event. + +### Limitations + +- `name`, `auto_archive_duration`, and archiving need the thread owner or a thread moderator. +- `locked` needs a thread moderator, and the thread owner can set it to true. +- `rate_limit_per_user` and `flags` need a thread moderator. +- `invitable` needs the thread owner or a thread moderator. +- Unarchiving needs a thread member or the thread owner, and needs a thread moderator when the thread is locked. +- An archived thread accepts no other change unless the same body sets `archived` to false. + +### Thread body + +| Field | Type | Description | +| --- | --- | --- | +| name? | string | The name of the thread (1-100 characters) | +| archived? | boolean | Whether the thread is archived | +| auto_archive_duration? | integer | The [auto archive duration](#auto-archive-durations) in minutes | +| locked? | boolean | Whether only thread moderators can act in the thread | +| rate_limit_per_user? | integer | The slowmode interval in seconds (0-21,600) | +| flags?1 | integer | The [channel flags](/http-api/forums/#channel-flags) of the thread | +| applied_tags?2 | array[snowflake] | The complete set of forum tag IDs (max 5) | +| invitable?3 | boolean | Whether a member who is not a thread moderator can add other members | + +1 Only `PINNED` on a forum or media post. Any other bit returns 400 `INVALID_FORM_BODY` + +2 Only on a forum or media post, and only by the thread owner or a thread moderator. Any other thread returns 400 `INVALID_CHANNEL_TYPE` + +3 Read only for a private thread + +Pinning a second post in one forum returns 400 `MAX_PINNED_THREADS_IN_FORUM`. Changing `auto_archive_duration` resets `archive_timestamp`. Renaming a thread posts a `CHANNEL_NAME_CHANGE` message in it. + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | [thread](#thread-object) object | Thread was modified | +| 400 | [error response](/http-api/#error-response) | Thread is archived and the body changes another field, returning `THREAD_ARCHIVED` | +| 400 | [error response](/http-api/#error-response) | Thread is locked and the caller is not a thread moderator, returning `THREAD_LOCKED` | +| 400 | [error response](/http-api/#error-response) | Unarchiving would exceed 1,000 active threads and the request returns `MAX_ACTIVE_THREADS` | +| 400 | [error response](/http-api/#error-response) | Forum already has a pinned post and the request returns `MAX_PINNED_THREADS_IN_FORUM` | +| 403 | [error response](/http-api/#error-response) | Caller lacks the owner or moderator role the field needs and the request returns `MISSING_PERMISSIONS` | +| 403 | [error response](/http-api/#error-response) | Caller is timed out and the request returns `COMMUNICATION_DISABLED` | + +### Rate limit + +Shared with [Modify channel](/http-api/channels/#modify-channel). + +## Delete thread + +[Delete or leave channel](/http-api/channels/#delete-or-leave-channel) on a thread deletes it with its messages and memberships. It requires `MANAGE_THREADS` and emits a [Thread Delete](/gateway/events/#thread-delete) Gateway event. + +### Side effects + +- The source message of the thread loses `HAS_THREAD`, and Fluxer emits [Message Update](/gateway/events/#message-update) for it. +- The guild audit log records action type 112. + +### Rate limit + +Shared with [Delete or leave channel](/http-api/channels/#delete-or-leave-channel). + +## List active guild threads + + + +Returns every active thread in the guild that the bot can view, newest first. Bot-only. + +A private thread is included when the bot is a member or a thread moderator in its parent. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| guild_id | snowflake | The ID of the guild | + +### Response body + +| Field | Type | Description | +| --- | --- | --- | +| threads | array[[thread](#thread-object) object] | The active threads | +| members | array[[thread member](/http-api/thread-members/#thread-member-object) object] | A thread member object for each returned thread the bot joined | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | active threads object | Threads were returned | +| 404 | [error response](/http-api/#error-response) | Caller is a user, and the request returns `NOT_FOUND` | + +### Rate limit + +5 requests per 10 seconds for each authenticated user and guild ID, on the `guild:threads:active::guild_id` bucket. + +## List public archived threads + + + +Returns the archived public threads of a guild text, announcement, forum, or media channel, most recently archived first. An announcement channel returns announcement threads. Requires `READ_MESSAGE_HISTORY`. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| channel_id | snowflake | The ID of the parent channel | + +### Query parameters + +| Field | Type | Description | +| --- | --- | --- | +| before? | ISO8601 timestamp | Return threads archived before this time | +| limit? | integer | The maximum number of threads to return (2-100, default 50) | + +### Response body + +| Field | Type | Description | +| --- | --- | --- | +| threads | array[[thread](#thread-object) object] | The archived threads | +| members | array[[thread member](/http-api/thread-members/#thread-member-object) object] | A thread member object for each returned thread the caller joined | +| has_more | boolean | Whether a later request can return more threads | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | archived threads object | Threads were returned | +| 400 | [error response](/http-api/#error-response) | Channel is not a thread parent and the request returns `INVALID_CHANNEL_TYPE` | +| 403 | [error response](/http-api/#error-response) | Caller lacks `READ_MESSAGE_HISTORY` and the request returns `MISSING_PERMISSIONS` | + +### Rate limit + +20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:threads:archived:list::channel_id` bucket. + +## List private archived threads + + + +Returns the archived private threads of a guild text channel, most recently archived first. Requires `READ_MESSAGE_HISTORY` and `MANAGE_THREADS`. + +The path parameters, query parameters, and response body match [List public archived threads](#list-public-archived-threads). An announcement, forum, or media channel returns 400 `INVALID_CHANNEL_TYPE`. + +### Rate limit + +20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:threads:archived:list::channel_id` bucket. + +## List joined private archived threads + + + +Returns the archived private threads of a guild text channel that the caller joined, newest first. Requires `READ_MESSAGE_HISTORY`. + +### Query parameters + +| Field | Type | Description | +| --- | --- | --- | +| before? | snowflake | Return threads with an ID below this thread ID | +| limit? | integer | The maximum number of threads to return (2-100, default 50) | + +The response body matches [List public archived threads](#list-public-archived-threads). + +### Rate limit + +20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:threads:archived:list::channel_id` bucket. + +## Search threads + + + +Searches the threads of a guild text, forum, or media channel by name and tag. Requires `READ_MESSAGE_HISTORY`. + +A caller who is not a thread moderator finds public threads and the private threads they joined. + +### Query parameters + +| Field | Type | Description | +| --- | --- | --- | +| name? | string | The text to match in thread names (max 100 characters) | +| tag? | array[snowflake] | The forum tag IDs to filter by, repeated once per tag (max 20) | +| tag_setting? | string | `match_some` or `match_all`, defaulting to the `default_tag_setting` of the channel | +| archived? | boolean | Whether to return only archived or only active threads (default both) | +| sort_by? | string | `last_message_time`, `archive_time`, `relevance`, or `creation_time` (default `last_message_time`) | +| sort_order? | string | `asc` or `desc` (default `desc`) | +| limit? | integer | The maximum number of threads to return (1-25, default 25) | +| offset? | integer | The number of threads to skip (0-9,975, default 0) | +| max_id? | snowflake | Return threads created before this ID | +| min_id? | snowflake | Return threads created after this ID | +| slop? | integer | A value Fluxer accepts and ignores (0-100, default 2) | + +### Response body + +| Field | Type | Description | +| --- | --- | --- | +| threads | array[[thread](#thread-object) object] | The matching threads | +| members | array[[thread member](/http-api/thread-members/#thread-member-object) object] | A thread member object for each returned thread the caller joined | +| has_more | boolean | Whether a later request can return more threads | +| total_results | integer | The number of matching threads | +| first_messages?1 | array[[message](/http-api/messages/#message-object) object] | The first message of each returned post | + +1 Present only for a forum or media channel + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | thread search object | Search completed | +| 202 | index not ready object | The guild thread index is being built | +| 400 | [error response](/http-api/#error-response) | Channel is not a thread parent and the request returns `INVALID_CHANNEL_TYPE` | +| 403 | [error response](/http-api/#error-response) | Caller lacks `READ_MESSAGE_HISTORY` and the request returns `MISSING_PERMISSIONS` | + +The 202 body is `code` `SEARCH_INDEX_NOT_READY`, `message`, `documents_indexed` of `0`, and `retry_after` in seconds. The first search in a guild starts the index build. + +### Rate limit + +20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:threads:search::channel_id` bucket. + +## Thread messages + +Two message types belong to threads. + +| Value | Name | Description | +| --- | --- | --- | +| 18 | THREAD_CREATED | Posted in the parent when a public thread starts | +| 21 | THREAD_STARTER_MESSAGE | The first message of a thread started from a message | + +`message_reference.channel_id` of a `THREAD_CREATED` message is the thread, and it has no `message_id`. A `THREAD_STARTER_MESSAGE` has the ID of its thread, references the source message, and cannot be deleted. When the source message is deleted, the thread stays and its `referenced_message` becomes null. + +A message that started a thread has `thread`, the [thread object](#thread-object), and the message flag `HAS_THREAD` (1 << 5). Mentioning roles whose members would push the thread past 250 automatic additions sets `FAILED_TO_MENTION_SOME_ROLES_IN_THREAD` (1 << 8). Only Fluxer sets either flag. + +Sending a message in a thread adds a user sender who is not a member. A mentioned user who can view the parent is added too, unless the thread is private and not `invitable`. + +## Thread fields on other objects + +| Object | Field | Description | +| --- | --- | --- | +| Guild text channel | default_auto_archive_duration | The auto archive duration new threads inherit | +| Guild text channel | default_thread_rate_limit_per_user | The slowmode interval new threads inherit | +| Read state | flags | `IS_GUILD_CHANNEL` (1 << 0) and `IS_THREAD` (1 << 1), set by Fluxer | +| Message search result | threads | The threads that contain a returned message | +| Message search result | members | A thread member object for each of those threads the caller joined | +| Audit log | threads | The threads referenced by the returned entries | +| Experiment assignments | channel_threads | `active` and `config_version`, present only for an account in the rollout | + +The guild audit log records thread creation, update, and deletion as action types 110, 111, and 112. + +## Thread error codes + +### `FORUM_TAG_NAMES_MUST_BE_UNIQUE` + +Tag names must be unique + +### `FORUM_TAG_REQUIRED` + +A tag is required to create a post in this channel + +### `HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY` + +Hiding media download options is only available in media channels + +### `INVALID_THREAD_NOTIFICATION_SETTINGS` + +Invalid thread notification settings + +### `MAX_ACTIVE_THREADS` + +This community has reached its active thread limit of {count} + +### `MAX_FORUM_TAGS` + +This channel has reached its tag limit of {count} + +### `MAX_PINNED_THREADS_IN_FORUM` + +This channel has reached its pinned post limit of {count} + +### `MAX_THREAD_MEMBERS` + +This thread has reached its member limit of {count} + +### `NO_TAGS_AVAILABLE_TO_NON_MODERATORS` + +A tag is required, but only moderators can apply the tags in this channel + +### `SEARCH_INDEX_NOT_READY` + +The search index isn't ready yet + +### `THREAD_ALREADY_CREATED_FOR_MESSAGE` + +A thread has already been created for this message + +### `THREAD_ARCHIVED` + +This thread is archived + +### `THREAD_LOCKED` + +This thread is locked + +### `UNKNOWN_FORUM_TAG` + +Tag wasn't found + +### `UNKNOWN_THREAD_MEMBER` + +Thread member wasn't found + +### `WEBHOOK_FORUM_TARGET_CONFLICT` + +You can't specify both `thread_id` and `thread_name` + +### `WEBHOOK_FORUM_TARGET_REQUIRED` + +Webhooks posting to this channel need a `thread_id` or a `thread_name` + +### `WEBHOOK_SERVICE_FORUM_UNSUPPORTED` + +This webhook service can't post to a forum or media channel without a `thread_id` + +### `WEBHOOK_THREAD_NAME_REQUIRES_FORUM` + +`thread_name` and `applied_tags` only work in forum and media channels diff --git a/knip.json b/knip.json index baf64a424..c78a7bc01 100644 --- a/knip.json +++ b/knip.json @@ -33,7 +33,6 @@ "fluxer_api": { "ignoreDependencies": [ "@elastic/elasticsearch", - "@hono/node-server", "@messageformat/core", "@messageformat/parser", "@vvo/tzdb", diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 3b3c052c6..7b1d8dd2a 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -105,6 +105,7 @@ export interface MasterConfig { lane?: 'realtime' | 'unfurl' | 'lifecycle' | 'batch' | 'crosspost'; task?: string; enable_cron_scheduler?: boolean; + metrics_port?: number; lane_concurrency_overrides?: { realtime?: number; unfurl?: number; diff --git a/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts b/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts index 81a40e14a..aac725427 100644 --- a/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts +++ b/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts @@ -15,6 +15,7 @@ const API_SETTINGS_NOT_FORWARDED: Record = { FLUXER_API_WORKER_LANE: 'the one worker container runs every lane', FLUXER_API_WORKER_TASK: 'the one worker container runs every lane', FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: 'the one worker container hosts cron', + FLUXER_API_WORKER_METRICS_PORT: 'the stack scrapes no worker metrics', FLUXER_RELAX_REGISTRATION_RATE_LIMITS: 'test and development only', FLUXER_DISABLE_RATE_LIMITS: 'test and development only', FLUXER_TEST_MODE_ENABLED: 'test and development only', diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index cf5983a89..dfe8ec4c3 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -94,6 +94,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { path: ['services', 'api', 'worker', 'enable_cron_scheduler'], parse: parseBoolean, }, + FLUXER_API_WORKER_METRICS_PORT: {path: ['services', 'api', 'worker', 'metrics_port'], parse: parseInteger}, FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: { path: ['services', 'api', 'worker', 'lane_concurrency_overrides'], parse: parseJsonObject, diff --git a/packages/constants/src/ApiErrorCodes.ts b/packages/constants/src/ApiErrorCodes.ts index 9dba3ceff..209474906 100644 --- a/packages/constants/src/ApiErrorCodes.ts +++ b/packages/constants/src/ApiErrorCodes.ts @@ -46,6 +46,7 @@ export const APIErrorCodes = { CAPTCHA_REQUIRED: 'CAPTCHA_REQUIRED', CHANNEL_ALREADY_FOLLOWED: 'CHANNEL_ALREADY_FOLLOWED', CHANNEL_HAS_FOLLOWED_CHANNELS: 'CHANNEL_HAS_FOLLOWED_CHANNELS', + CHANNEL_HAS_THREADS: 'CHANNEL_HAS_THREADS', CHANNEL_TYPE_CONVERSION_NOT_SUPPORTED: 'CHANNEL_TYPE_CONVERSION_NOT_SUPPORTED', COMMUNICATION_DISABLED: 'COMMUNICATION_DISABLED', CONNECTION_ALREADY_EXISTS: 'CONNECTION_ALREADY_EXISTS', @@ -93,6 +94,8 @@ export const APIErrorCodes = { FEATURE_TEMPORARILY_DISABLED: 'FEATURE_TEMPORARILY_DISABLED', FILE_SIZE_TOO_LARGE: 'FILE_SIZE_TOO_LARGE', FORBIDDEN: 'FORBIDDEN', + FORUM_TAG_NAMES_MUST_BE_UNIQUE: 'FORUM_TAG_NAMES_MUST_BE_UNIQUE', + FORUM_TAG_REQUIRED: 'FORUM_TAG_REQUIRED', FRIEND_REQUEST_BLOCKED: 'FRIEND_REQUEST_BLOCKED', GATEWAY_TIMEOUT: 'GATEWAY_TIMEOUT', GENERAL_ERROR: 'GENERAL_ERROR', @@ -104,6 +107,7 @@ export const APIErrorCodes = { HARVEST_EXPIRED: 'HARVEST_EXPIRED', HARVEST_FAILED: 'HARVEST_FAILED', HARVEST_NOT_READY: 'HARVEST_NOT_READY', + HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY: 'HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY', INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', INVALID_ACLS_FORMAT: 'INVALID_ACLS_FORMAT', INVALID_API_ORIGIN: 'INVALID_API_ORIGIN', @@ -128,6 +132,7 @@ export const APIErrorCodes = { INVALID_SYSTEM_FLAG: 'INVALID_SYSTEM_FLAG', INVALID_TIMESTAMP: 'INVALID_TIMESTAMP', INVALID_TOKEN: 'INVALID_TOKEN', + INVALID_THREAD_NOTIFICATION_SETTINGS: 'INVALID_THREAD_NOTIFICATION_SETTINGS', INVALID_WEBAUTHN_AUTHENTICATION_COUNTER: 'INVALID_WEBAUTHN_AUTHENTICATION_COUNTER', INVALID_WEBAUTHN_CREDENTIAL_COUNTER: 'INVALID_WEBAUTHN_CREDENTIAL_COUNTER', INVALID_WEBAUTHN_CREDENTIAL: 'INVALID_WEBAUTHN_CREDENTIAL', @@ -141,10 +146,12 @@ export const APIErrorCodes = { IP_BAN_DECLINED: 'IP_BAN_DECLINED', MAX_APPLICATIONS: 'MAX_APPLICATIONS', MAX_BOOKMARKS: 'MAX_BOOKMARKS', + MAX_ACTIVE_THREADS: 'MAX_ACTIVE_THREADS', MAX_CATEGORY_CHANNELS: 'MAX_CATEGORY_CHANNELS', MAX_EMOJIS: 'MAX_EMOJIS', MAX_FAVORITE_MEMES: 'MAX_FAVORITE_MEMES', MAX_FRIENDS: 'MAX_FRIENDS', + MAX_FORUM_TAGS: 'MAX_FORUM_TAGS', MAX_GROUP_DM_RECIPIENTS: 'MAX_GROUP_DM_RECIPIENTS', MAX_GROUP_DMS: 'MAX_GROUP_DMS', GROUP_DM_RECIPIENTS_NOT_ADDABLE: 'GROUP_DM_RECIPIENTS_NOT_ADDABLE', @@ -152,9 +159,11 @@ export const APIErrorCodes = { MAX_GUILD_MEMBERS: 'MAX_GUILD_MEMBERS', MAX_GUILD_ROLES: 'MAX_GUILD_ROLES', MAX_GUILDS: 'MAX_GUILDS', + MAX_PINNED_THREADS_IN_FORUM: 'MAX_PINNED_THREADS_IN_FORUM', MAX_INVITES: 'MAX_INVITES', MAX_REACTIONS: 'MAX_REACTIONS', MAX_STICKERS: 'MAX_STICKERS', + MAX_THREAD_MEMBERS: 'MAX_THREAD_MEMBERS', MAX_WEBHOOKS_PER_CHANNEL: 'MAX_WEBHOOKS_PER_CHANNEL', MAX_WEBHOOKS_PER_GUILD: 'MAX_WEBHOOKS_PER_GUILD', NCMEC_ALREADY_SUBMITTED: 'NCMEC_ALREADY_SUBMITTED', @@ -168,6 +177,7 @@ export const APIErrorCodes = { MISSING_PERMISSIONS: 'MISSING_PERMISSIONS', NO_ACTIVE_CALL: 'NO_ACTIVE_CALL', NO_ACTIVE_SUBSCRIPTION: 'NO_ACTIVE_SUBSCRIPTION', + NO_TAGS_AVAILABLE_TO_NON_MODERATORS: 'NO_TAGS_AVAILABLE_TO_NON_MODERATORS', NOT_FOUND: 'NOT_FOUND', NOT_IMPLEMENTED: 'NOT_IMPLEMENTED', NO_PASSKEYS_REGISTERED: 'NO_PASSKEYS_REGISTERED', @@ -192,6 +202,7 @@ export const APIErrorCodes = { SINGLE_COMMUNITY_CANNOT_LEAVE: 'SINGLE_COMMUNITY_CANNOT_LEAVE', INSTANCE_POLICY_TRANSITION_NOT_ALLOWED: 'INSTANCE_POLICY_TRANSITION_NOT_ALLOWED', SLOWMODE_RATE_LIMITED: 'SLOWMODE_RATE_LIMITED', + SEARCH_INDEX_NOT_READY: 'SEARCH_INDEX_NOT_READY', SSO_REQUIRED: 'SSO_REQUIRED', STORE_BILLING_UNAVAILABLE: 'STORE_BILLING_UNAVAILABLE', STORE_NOTIFICATION_UNAUTHORIZED: 'STORE_NOTIFICATION_UNAUTHORIZED', @@ -226,6 +237,9 @@ export const APIErrorCodes = { TEMPORARY_INVITE_REQUIRES_PRESENCE: 'TEMPORARY_INVITE_REQUIRES_PRESENCE', TEST_HARNESS_DISABLED: 'TEST_HARNESS_DISABLED', TEST_HARNESS_FORBIDDEN: 'TEST_HARNESS_FORBIDDEN', + THREAD_ALREADY_CREATED_FOR_MESSAGE: 'THREAD_ALREADY_CREATED_FOR_MESSAGE', + THREAD_ARCHIVED: 'THREAD_ARCHIVED', + THREAD_LOCKED: 'THREAD_LOCKED', TWO_FA_NOT_ENABLED: 'TWO_FA_NOT_ENABLED', TWO_FACTOR_REQUIRED: 'TWO_FACTOR_REQUIRED', UNAUTHORIZED: 'UNAUTHORIZED', @@ -244,6 +258,7 @@ export const APIErrorCodes = { UNKNOWN_CHANNEL: 'UNKNOWN_CHANNEL', UNKNOWN_EMOJI: 'UNKNOWN_EMOJI', UNKNOWN_FAVORITE_MEME: 'UNKNOWN_FAVORITE_MEME', + UNKNOWN_FORUM_TAG: 'UNKNOWN_FORUM_TAG', UNKNOWN_GIFT_CODE: 'UNKNOWN_GIFT_CODE', UNKNOWN_GUILD: 'UNKNOWN_GUILD', UNKNOWN_HARVEST: 'UNKNOWN_HARVEST', @@ -257,6 +272,7 @@ export const APIErrorCodes = { UNKNOWN_ROLE: 'UNKNOWN_ROLE', UNKNOWN_STICKER: 'UNKNOWN_STICKER', UNKNOWN_STORE_PURCHASE: 'UNKNOWN_STORE_PURCHASE', + UNKNOWN_THREAD_MEMBER: 'UNKNOWN_THREAD_MEMBER', UNKNOWN_USER_FLAG: 'UNKNOWN_USER_FLAG', UNKNOWN_USER: 'UNKNOWN_USER', UNKNOWN_VOICE_REGION: 'UNKNOWN_VOICE_REGION', @@ -264,6 +280,10 @@ export const APIErrorCodes = { UNKNOWN_WEBAUTHN_CREDENTIAL: 'UNKNOWN_WEBAUTHN_CREDENTIAL', UNKNOWN_APPLICATION: 'UNKNOWN_APPLICATION', UNKNOWN_WEBHOOK: 'UNKNOWN_WEBHOOK', + WEBHOOK_FORUM_TARGET_CONFLICT: 'WEBHOOK_FORUM_TARGET_CONFLICT', + WEBHOOK_FORUM_TARGET_REQUIRED: 'WEBHOOK_FORUM_TARGET_REQUIRED', + WEBHOOK_SERVICE_FORUM_UNSUPPORTED: 'WEBHOOK_SERVICE_FORUM_UNSUPPORTED', + WEBHOOK_THREAD_NAME_REQUIRES_FORUM: 'WEBHOOK_THREAD_NAME_REQUIRES_FORUM', USERNAME_NOT_AVAILABLE: 'USERNAME_NOT_AVAILABLE', USERNAME_SIGN_IN_ONLY: 'USERNAME_SIGN_IN_ONLY', UPDATE_FAILED: 'UPDATE_FAILED', diff --git a/packages/constants/src/AuditLogActionType.ts b/packages/constants/src/AuditLogActionType.ts index b8acf5b08..bd7a57cf4 100644 --- a/packages/constants/src/AuditLogActionType.ts +++ b/packages/constants/src/AuditLogActionType.ts @@ -36,4 +36,7 @@ export enum AuditLogActionType { MESSAGE_BULK_DELETE = 73, MESSAGE_PIN = 74, MESSAGE_UNPIN = 75, + THREAD_CREATE = 110, + THREAD_UPDATE = 111, + THREAD_DELETE = 112, } diff --git a/packages/constants/src/BotPermissionUtils.test.ts b/packages/constants/src/BotPermissionUtils.test.ts index 031fc09c3..8b71dd3c5 100644 --- a/packages/constants/src/BotPermissionUtils.test.ts +++ b/packages/constants/src/BotPermissionUtils.test.ts @@ -41,3 +41,30 @@ describe('canAuthorizeBotInvite', () => { ).toBe(true); }); }); + +describe('bot invite permission mask', () => { + const threadBit = 1n << 38n; + + it('drops bits outside the default mask', () => { + expect(normalizeBotInvitePermissions(threadBit | Permissions.SEND_MESSAGES)).toBe(Permissions.SEND_MESSAGES); + }); + + it('keeps bits inside a wider mask', () => { + const mask = Permissions.SEND_MESSAGES | threadBit; + expect(normalizeBotInvitePermissions(threadBit | Permissions.SEND_MESSAGES, mask)).toBe(mask); + expect( + canAuthorizeBotInvite({ + userPermissions: Permissions.MANAGE_GUILD, + requestedPermissions: threadBit, + mask, + }), + ).toBe(false); + expect( + canAuthorizeBotInvite({ + userPermissions: Permissions.MANAGE_GUILD | threadBit, + requestedPermissions: threadBit, + mask, + }), + ).toBe(true); + }); +}); diff --git a/packages/constants/src/BotPermissionUtils.ts b/packages/constants/src/BotPermissionUtils.ts index c79f58cb3..85aa88c22 100644 --- a/packages/constants/src/BotPermissionUtils.ts +++ b/packages/constants/src/BotPermissionUtils.ts @@ -2,8 +2,8 @@ import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants'; -export function normalizeBotInvitePermissions(requestedPermissions: bigint): bigint { - return requestedPermissions & ALL_PERMISSIONS; +export function normalizeBotInvitePermissions(requestedPermissions: bigint, mask: bigint = ALL_PERMISSIONS): bigint { + return requestedPermissions & mask; } function hasAdministratorPermission(permissions: bigint): boolean { @@ -17,11 +17,13 @@ function hasManageGuildPermission(permissions: bigint): boolean { export function canAuthorizeBotInvite({ userPermissions, requestedPermissions, + mask = ALL_PERMISSIONS, }: { userPermissions: bigint; requestedPermissions?: bigint | null; + mask?: bigint; }): boolean { - const normalizedRequestedPermissions = normalizeBotInvitePermissions(requestedPermissions ?? 0n); + const normalizedRequestedPermissions = normalizeBotInvitePermissions(requestedPermissions ?? 0n, mask); if (!hasAdministratorPermission(userPermissions) && !hasManageGuildPermission(userPermissions)) { return false; } diff --git a/packages/constants/src/ChannelConstants.ts b/packages/constants/src/ChannelConstants.ts index e5ee46e6a..19c97556e 100644 --- a/packages/constants/src/ChannelConstants.ts +++ b/packages/constants/src/ChannelConstants.ts @@ -9,6 +9,11 @@ export const ChannelTypes = { GROUP_DM: 3, GUILD_CATEGORY: 4, GUILD_ANNOUNCEMENT: 5, + ANNOUNCEMENT_THREAD: 10, + PUBLIC_THREAD: 11, + PRIVATE_THREAD: 12, + GUILD_FORUM: 15, + GUILD_MEDIA: 16, GUILD_LINK: 998, DM_PERSONAL_NOTES: 999, } as const; @@ -59,7 +64,9 @@ export const MessageTypes = { CHANNEL_PINNED_MESSAGE: 6, USER_JOIN: 7, CHANNEL_FOLLOW_ADD: 12, + THREAD_CREATED: 18, REPLY: 19, + THREAD_STARTER_MESSAGE: 21, CLIENT_SYSTEM: 99, } as const; @@ -71,6 +78,8 @@ const MESSAGE_TYPE_DELETABLE = { [MessageTypes.CHANNEL_PINNED_MESSAGE]: true, [MessageTypes.USER_JOIN]: true, [MessageTypes.CHANNEL_FOLLOW_ADD]: true, + [MessageTypes.THREAD_CREATED]: true, + [MessageTypes.THREAD_STARTER_MESSAGE]: false, [MessageTypes.RECIPIENT_ADD]: false, [MessageTypes.RECIPIENT_REMOVE]: false, [MessageTypes.CALL]: false, diff --git a/packages/constants/src/GatewayConstants.ts b/packages/constants/src/GatewayConstants.ts index 8a874fcdf..971e14a70 100644 --- a/packages/constants/src/GatewayConstants.ts +++ b/packages/constants/src/GatewayConstants.ts @@ -19,6 +19,7 @@ export const GatewayOpcodes = { LAZY_REQUEST: 14, REQUEST_GUILD_COUNTS: 15, REQUEST_CHANNEL_MEMBER_COUNTS: 16, + REQUEST_FORUM_UNREADS: 28, } as const; export const LARGE_GUILD_THRESHOLD = 250; export const MEMBER_LIST_RANGE_MAX_END = 100000; @@ -27,6 +28,7 @@ export const MEMBER_LIST_RANGE_PAGE_SIZE = MEMBER_LIST_RANGE_MAX_SPAN + 1; export const MEMBER_LIST_RANGE_WINDOW_OVERSCAN_PAGES = 1; export const GatewayIdentifyFlags = { DEBOUNCE_MESSAGE_REACTIONS: 1 << 1, + CHANNEL_THREADS: 1 << 2, } as const; export const GatewayCloseCodes = { UNKNOWN_ERROR: 4000, diff --git a/packages/constants/src/ThreadConstants.test.ts b/packages/constants/src/ThreadConstants.test.ts new file mode 100644 index 000000000..b66c53e03 --- /dev/null +++ b/packages/constants/src/ThreadConstants.test.ts @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import * as ChannelConstants from '@fluxer/constants/src/ChannelConstants'; +import { + ChannelTypes, + GUILD_TEXT_BASED_CHANNEL_TYPES, + isMessageTypeDeletable, + MessageTypes, + TEXT_BASED_CHANNEL_TYPES, +} from '@fluxer/constants/src/ChannelConstants'; +import * as ThreadConstants from '@fluxer/constants/src/ThreadConstants'; +import { + ChannelFlags, + isThreadAutoArchiveDuration, + isValidThreadMemberSettingsFlags, + publicThreadTypeFor, + resolveTextThreadType, + settableChannelFlags, + THREAD_CHANNEL_TYPES, + THREAD_FEATURE_CHANNEL_TYPES, + THREAD_MESSAGE_FLAG_MASK, + THREAD_ONLY_CHANNEL_TYPES, + THREAD_PARENT_CHANNEL_TYPES, + ThreadMemberFlags, +} from '@fluxer/constants/src/ThreadConstants'; +import {describe, expect, it} from 'vitest'; + +function numericValues(value: unknown): Array { + if (typeof value === 'number') return [value]; + if (value instanceof Set) return [...value].flatMap(numericValues); + if (Array.isArray(value)) return value.flatMap(numericValues); + if (value !== null && typeof value === 'object') return Object.values(value).flatMap(numericValues); + return []; +} + +describe('thread channel and message types', () => { + it('uses the expected channel type numbers', () => { + expect(ChannelTypes.ANNOUNCEMENT_THREAD).toBe(10); + expect(ChannelTypes.PUBLIC_THREAD).toBe(11); + expect(ChannelTypes.PRIVATE_THREAD).toBe(12); + expect(ChannelTypes.GUILD_FORUM).toBe(15); + expect(ChannelTypes.GUILD_MEDIA).toBe(16); + expect(MessageTypes.THREAD_CREATED).toBe(18); + expect(MessageTypes.THREAD_STARTER_MESSAGE).toBe(21); + }); + + it('keeps the text type sets unchanged', () => { + expect([...GUILD_TEXT_BASED_CHANNEL_TYPES].sort((a, b) => a - b)).toEqual([ + ChannelTypes.GUILD_TEXT, + ChannelTypes.GUILD_VOICE, + ChannelTypes.GUILD_ANNOUNCEMENT, + ]); + expect([...TEXT_BASED_CHANNEL_TYPES].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 5, 999]); + for (const type of THREAD_FEATURE_CHANNEL_TYPES) { + expect(TEXT_BASED_CHANNEL_TYPES.has(type)).toBe(false); + } + }); + + it('makes announcement channels thread parents of announcement threads', () => { + expect(THREAD_CHANNEL_TYPES.has(ChannelTypes.ANNOUNCEMENT_THREAD)).toBe(true); + expect(THREAD_PARENT_CHANNEL_TYPES.has(ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(true); + expect(THREAD_ONLY_CHANNEL_TYPES.has(ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(false); + expect(publicThreadTypeFor(ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(ChannelTypes.ANNOUNCEMENT_THREAD); + expect(publicThreadTypeFor(ChannelTypes.GUILD_TEXT)).toBe(ChannelTypes.PUBLIC_THREAD); + expect(publicThreadTypeFor(ChannelTypes.GUILD_FORUM)).toBe(ChannelTypes.PUBLIC_THREAD); + }); + + it('resolves the thread type a text or announcement parent creates', () => { + const {GUILD_TEXT, GUILD_ANNOUNCEMENT, GUILD_FORUM, ANNOUNCEMENT_THREAD, PUBLIC_THREAD, PRIVATE_THREAD} = + ChannelTypes; + expect(resolveTextThreadType(GUILD_ANNOUNCEMENT, ANNOUNCEMENT_THREAD)).toBe(ANNOUNCEMENT_THREAD); + expect(resolveTextThreadType(GUILD_ANNOUNCEMENT, PUBLIC_THREAD)).toBe(ANNOUNCEMENT_THREAD); + expect(resolveTextThreadType(GUILD_ANNOUNCEMENT, PRIVATE_THREAD)).toBeNull(); + expect(resolveTextThreadType(GUILD_TEXT, PUBLIC_THREAD)).toBe(PUBLIC_THREAD); + expect(resolveTextThreadType(GUILD_TEXT, PRIVATE_THREAD)).toBe(PRIVATE_THREAD); + expect(resolveTextThreadType(GUILD_TEXT, ANNOUNCEMENT_THREAD)).toBeNull(); + expect(resolveTextThreadType(GUILD_FORUM, PUBLIC_THREAD)).toBeNull(); + }); + + it('lets thread created messages be deleted but never the synthesized starter', () => { + expect(isMessageTypeDeletable(MessageTypes.THREAD_CREATED)).toBe(true); + expect(isMessageTypeDeletable(MessageTypes.THREAD_STARTER_MESSAGE)).toBe(false); + }); + + it('masks both server-only message flags', () => { + expect(THREAD_MESSAGE_FLAG_MASK).toBe(0x120); + expect(THREAD_MESSAGE_FLAG_MASK & ChannelConstants.SENDABLE_MESSAGE_FLAGS).toBe(0); + }); +}); + +describe('thread constants', () => { + it('keeps every numeric value inside a non-negative int32', () => { + for (const value of numericValues(ThreadConstants)) { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(2 ** 31); + } + }); + + it('accepts only the supported auto archive durations', () => { + for (const duration of [60, 1440, 4320, 10080]) { + expect(isThreadAutoArchiveDuration(duration)).toBe(true); + } + for (const duration of [0, 59, 61, 10081]) { + expect(isThreadAutoArchiveDuration(duration)).toBe(false); + } + }); + + it('allows at most one notification bit on thread member settings', () => { + expect(isValidThreadMemberSettingsFlags(0)).toBe(true); + expect(isValidThreadMemberSettingsFlags(ThreadMemberFlags.ALL_MESSAGES)).toBe(true); + expect(isValidThreadMemberSettingsFlags(ThreadMemberFlags.NO_MESSAGES)).toBe(true); + expect(isValidThreadMemberSettingsFlags(ThreadMemberFlags.ALL_MESSAGES | ThreadMemberFlags.ONLY_MENTIONS)).toBe( + false, + ); + expect(isValidThreadMemberSettingsFlags(ThreadMemberFlags.HAS_INTERACTED)).toBe(false); + expect(isValidThreadMemberSettingsFlags(1 << 4)).toBe(false); + expect(isValidThreadMemberSettingsFlags(-1)).toBe(false); + expect(isValidThreadMemberSettingsFlags(2 ** 32 + 2)).toBe(false); + expect(isValidThreadMemberSettingsFlags(2 ** 32)).toBe(false); + }); + + it('limits settable channel flags by type', () => { + expect(settableChannelFlags(ChannelTypes.GUILD_TEXT)).toBe(0); + expect(settableChannelFlags(ChannelTypes.PRIVATE_THREAD)).toBe(0); + expect(settableChannelFlags(ChannelTypes.PUBLIC_THREAD)).toBe(0); + expect(settableChannelFlags(ChannelTypes.PUBLIC_THREAD, ChannelTypes.GUILD_TEXT)).toBe(0); + expect(settableChannelFlags(ChannelTypes.PUBLIC_THREAD, ChannelTypes.GUILD_FORUM)).toBe(ChannelFlags.PINNED); + expect(settableChannelFlags(ChannelTypes.PUBLIC_THREAD, ChannelTypes.GUILD_MEDIA)).toBe(ChannelFlags.PINNED); + expect(settableChannelFlags(ChannelTypes.PRIVATE_THREAD, ChannelTypes.GUILD_TEXT)).toBe(0); + expect(settableChannelFlags(ChannelTypes.ANNOUNCEMENT_THREAD, ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(0); + expect(settableChannelFlags(ChannelTypes.GUILD_FORUM)).toBe(ChannelFlags.REQUIRE_TAG); + expect(settableChannelFlags(ChannelTypes.GUILD_MEDIA)).toBe( + ChannelFlags.REQUIRE_TAG | ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS, + ); + }); +}); diff --git a/packages/constants/src/ThreadConstants.ts b/packages/constants/src/ThreadConstants.ts new file mode 100644 index 000000000..389a1f79c --- /dev/null +++ b/packages/constants/src/ThreadConstants.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import type {ValueOf} from '@fluxer/constants/src/ValueOf'; + +export const THREAD_CHANNEL_TYPES = new Set([ + ChannelTypes.ANNOUNCEMENT_THREAD, + ChannelTypes.PUBLIC_THREAD, + ChannelTypes.PRIVATE_THREAD, +]); +export const PUBLIC_THREAD_CHANNEL_TYPES = new Set([ + ChannelTypes.ANNOUNCEMENT_THREAD, + ChannelTypes.PUBLIC_THREAD, +]); +export const THREAD_ONLY_CHANNEL_TYPES = new Set([ChannelTypes.GUILD_FORUM, ChannelTypes.GUILD_MEDIA]); +export const TEXT_THREAD_PARENT_CHANNEL_TYPES = new Set([ + ChannelTypes.GUILD_TEXT, + ChannelTypes.GUILD_ANNOUNCEMENT, +]); +export const THREAD_PARENT_CHANNEL_TYPES = new Set([ + ...TEXT_THREAD_PARENT_CHANNEL_TYPES, + ...THREAD_ONLY_CHANNEL_TYPES, +]); +export const THREAD_FEATURE_CHANNEL_TYPES = new Set([...THREAD_CHANNEL_TYPES, ...THREAD_ONLY_CHANNEL_TYPES]); + +export function publicThreadTypeFor(parentType: number): number { + return parentType === ChannelTypes.GUILD_ANNOUNCEMENT ? ChannelTypes.ANNOUNCEMENT_THREAD : ChannelTypes.PUBLIC_THREAD; +} + +export function resolveTextThreadType(parentType: number, requestedType: number): number | null { + if (parentType === ChannelTypes.GUILD_ANNOUNCEMENT) { + return PUBLIC_THREAD_CHANNEL_TYPES.has(requestedType) ? ChannelTypes.ANNOUNCEMENT_THREAD : null; + } + if (parentType !== ChannelTypes.GUILD_TEXT || requestedType === ChannelTypes.ANNOUNCEMENT_THREAD) return null; + return THREAD_CHANNEL_TYPES.has(requestedType) ? requestedType : null; +} + +export const ServerMessageFlags = { + HAS_THREAD: 1 << 5, + FAILED_TO_MENTION_SOME_ROLES_IN_THREAD: 1 << 8, +} as const; +export const THREAD_MESSAGE_FLAG_MASK = + ServerMessageFlags.HAS_THREAD | ServerMessageFlags.FAILED_TO_MENTION_SOME_ROLES_IN_THREAD; + +export const ChannelFlags = { + PINNED: 1 << 1, + REQUIRE_TAG: 1 << 4, + HIDE_MEDIA_DOWNLOAD_OPTIONS: 1 << 15, +} as const; + +export type ChannelFlag = ValueOf; + +export function settableChannelFlags(type: number, parentType?: number | null): number { + switch (type) { + case ChannelTypes.PUBLIC_THREAD: + return parentType != null && THREAD_ONLY_CHANNEL_TYPES.has(parentType) ? ChannelFlags.PINNED : 0; + case ChannelTypes.GUILD_FORUM: + return ChannelFlags.REQUIRE_TAG; + case ChannelTypes.GUILD_MEDIA: + return ChannelFlags.REQUIRE_TAG | ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS; + default: + return 0; + } +} + +export const ThreadMemberFlags = { + HAS_INTERACTED: 1 << 0, + ALL_MESSAGES: 1 << 1, + ONLY_MENTIONS: 1 << 2, + NO_MESSAGES: 1 << 3, +} as const; + +export const THREAD_MEMBER_NOTIFICATION_FLAG_MASK = + ThreadMemberFlags.ALL_MESSAGES | ThreadMemberFlags.ONLY_MENTIONS | ThreadMemberFlags.NO_MESSAGES; + +export function isValidThreadMemberSettingsFlags(flags: number): boolean { + if (!Number.isInteger(flags) || flags < 0 || flags > THREAD_MEMBER_NOTIFICATION_FLAG_MASK) return false; + if ((flags & ~THREAD_MEMBER_NOTIFICATION_FLAG_MASK) !== 0) return false; + const notify = flags & THREAD_MEMBER_NOTIFICATION_FLAG_MASK; + return (notify & (notify - 1)) === 0; +} + +export const ReadStateFlags = { + IS_GUILD_CHANNEL: 1 << 0, + IS_THREAD: 1 << 1, +} as const; + +export const ChannelOverrideFlags = { + NEW_FORUM_THREADS_OFF: 1 << 13, + NEW_FORUM_THREADS_ON: 1 << 14, +} as const; + +export const CHANNEL_OVERRIDE_FLAG_MASK = + ChannelOverrideFlags.NEW_FORUM_THREADS_OFF | ChannelOverrideFlags.NEW_FORUM_THREADS_ON; + +export const ForumLayoutTypes = { + DEFAULT: 0, + LIST: 1, + GRID: 2, +} as const; + +export type ForumLayoutType = ValueOf; + +export const ForumSortOrderTypes = { + LATEST_ACTIVITY: 0, + CREATION_TIME: 1, +} as const; + +export type ForumSortOrderType = ValueOf; + +export const ForumTagSettings = { + MATCH_SOME: 'match_some', + MATCH_ALL: 'match_all', +} as const; + +export type ForumTagSetting = ValueOf; + +export const THREAD_AUTO_ARCHIVE_DURATIONS = [60, 1440, 4320, 10080] as const; + +export type ThreadAutoArchiveDuration = (typeof THREAD_AUTO_ARCHIVE_DURATIONS)[number]; + +export const DEFAULT_THREAD_AUTO_ARCHIVE_DURATION: ThreadAutoArchiveDuration = 4320; + +export function isThreadAutoArchiveDuration(value: number): value is ThreadAutoArchiveDuration { + return (THREAD_AUTO_ARCHIVE_DURATIONS as ReadonlyArray).includes(value); +} + +export const THREAD_NAME_MIN_LENGTH = 1; +export const THREAD_NAME_MAX_LENGTH = 100; +export const FORUM_TOPIC_MAX_LENGTH = 4096; +export const MAX_FORUM_TAGS_PER_CHANNEL = 20; +export const FORUM_TAG_NAME_MAX_LENGTH = 50; +export const MAX_APPLIED_TAGS_PER_THREAD = 5; +export const MAX_THREAD_MEMBERS = 1000; +export const THREAD_MEMBER_COUNT_DISPLAY_CAP = 50; +export const THREAD_MEMBER_IDS_PREVIEW_SIZE = 8; +export const MAX_ACTIVE_THREADS_PER_GUILD = 1000; +export const MAX_PINNED_THREADS_PER_FORUM = 1; +export const MAX_ROLE_MENTION_THREAD_ADDS = 250; +export const THREAD_ARCHIVED_LIST_MIN_LIMIT = 2; +export const THREAD_ARCHIVED_LIST_MAX_LIMIT = 100; +export const THREAD_ARCHIVED_LIST_DEFAULT_LIMIT = 50; +export const THREAD_SEARCH_MIN_LIMIT = 1; +export const THREAD_SEARCH_MAX_LIMIT = 25; +export const THREAD_SEARCH_MAX_OFFSET = 9975; +export const THREAD_SEARCH_NAME_MAX_LENGTH = 100; +export const THREAD_SEARCH_MAX_TAGS = 20; +export const POST_DATA_MAX_IDS = 100; +export const FORUM_UNREADS_MAX_THREADS = 40; +export const FORUM_UNREAD_COUNT_CAP = 25; +export const MAX_THREAD_ARCHIVES_PER_SWEEP_TICK = 200; +export const THREAD_SCOPE_MAX = 5000; +export const SEARCH_INDEX_ENQUEUE_MAX = 50; +export const THREAD_RATE_LIMIT_PER_USER_MAX = 21600; diff --git a/packages/constants/src/ThreadPermissionCases.json b/packages/constants/src/ThreadPermissionCases.json new file mode 100644 index 000000000..3cd08ae1a --- /dev/null +++ b/packages/constants/src/ThreadPermissionCases.json @@ -0,0 +1,1457 @@ +[ + { + "name": "owner is a moderator", + "fn": "isThreadModerator", + "actor": { + "perms": [], + "isOwner": true + }, + "expect": true + }, + { + "name": "administrator is a moderator", + "fn": "isThreadModerator", + "actor": { + "perms": ["ADMINISTRATOR"] + }, + "expect": true + }, + { + "name": "manage threads is a moderator", + "fn": "isThreadModerator", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "expect": true + }, + { + "name": "timed out manage threads is not a moderator", + "fn": "isThreadModerator", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"], + "timedOut": true + }, + "expect": false + }, + { + "name": "timed out administrator stays a moderator", + "fn": "isThreadModerator", + "actor": { + "perms": ["ADMINISTRATOR"], + "timedOut": true + }, + "expect": true + }, + { + "name": "plain member is not a moderator", + "fn": "isThreadModerator", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "expect": false + }, + { + "name": "public thread needs parent view", + "fn": "canViewThread", + "actor": { + "perms": ["VIEW_CHANNEL"] + }, + "expect": null + }, + { + "name": "public thread without parent view", + "fn": "canViewThread", + "actor": { + "perms": [] + }, + "expect": "MISSING_ACCESS" + }, + { + "name": "private thread visible to members", + "fn": "canViewThread", + "actor": { + "perms": ["VIEW_CHANNEL"], + "isMember": true + }, + "thread": { + "type": 12 + }, + "expect": null + }, + { + "name": "private thread hidden from non-members", + "fn": "canViewThread", + "actor": { + "perms": ["VIEW_CHANNEL"] + }, + "thread": { + "type": 12 + }, + "expect": "MISSING_ACCESS" + }, + { + "name": "private thread visible to moderators", + "fn": "canViewThread", + "actor": { + "perms": ["VIEW_CHANNEL", "MANAGE_THREADS"] + }, + "thread": { + "type": 12 + }, + "expect": null + }, + { + "name": "private thread hidden from timed out moderators", + "fn": "canViewThread", + "actor": { + "perms": ["VIEW_CHANNEL", "MANAGE_THREADS"], + "timedOut": true + }, + "thread": { + "type": 12 + }, + "expect": "MISSING_ACCESS" + }, + { + "name": "private thread visible to administrators", + "fn": "canViewThread", + "actor": { + "perms": ["ADMINISTRATOR"] + }, + "thread": { + "type": 12 + }, + "expect": null + }, + { + "name": "private thread member without parent view", + "fn": "canViewThread", + "actor": { + "perms": [], + "isMember": true + }, + "thread": { + "type": 12 + }, + "expect": "MISSING_ACCESS" + }, + { + "name": "announcement thread visible with parent view", + "fn": "canViewThread", + "actor": { + "perms": ["VIEW_CHANNEL"] + }, + "thread": { + "type": 10 + }, + "expect": null + }, + { + "name": "announcement thread hidden without parent view", + "fn": "canViewThread", + "actor": { + "perms": [] + }, + "thread": { + "type": 10 + }, + "expect": "MISSING_ACCESS" + }, + { + "name": "send in an open thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "args": { + "action": "send" + }, + "expect": null + }, + { + "name": "send in an archived unlocked thread unarchives", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "archived": true + }, + "args": { + "action": "send" + }, + "expect": null + }, + { + "name": "send in a locked thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "locked": true + }, + "args": { + "action": "send" + }, + "expect": "THREAD_LOCKED" + }, + { + "name": "send in an archived locked thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "archived": true, + "locked": true + }, + "args": { + "action": "send" + }, + "expect": "THREAD_LOCKED" + }, + { + "name": "moderator sends in a locked thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "thread": { + "archived": true, + "locked": true + }, + "args": { + "action": "send" + }, + "expect": null + }, + { + "name": "timed out send", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "timedOut": true + }, + "args": { + "action": "send" + }, + "expect": "COMMUNICATION_DISABLED" + }, + { + "name": "timed out owner sends", + "fn": "threadWriteBlock", + "actor": { + "perms": [], + "timedOut": true, + "isOwner": true + }, + "args": { + "action": "send" + }, + "expect": null + }, + { + "name": "react in an archived thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "archived": true + }, + "args": { + "action": "react" + }, + "expect": "THREAD_ARCHIVED" + }, + { + "name": "moderator reacts in an archived thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "thread": { + "archived": true + }, + "args": { + "action": "react" + }, + "expect": "THREAD_ARCHIVED" + }, + { + "name": "pin in a locked thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "locked": true + }, + "args": { + "action": "pin" + }, + "expect": "THREAD_LOCKED" + }, + { + "name": "moderator pins in a locked thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "thread": { + "locked": true + }, + "args": { + "action": "pin" + }, + "expect": null + }, + { + "name": "edit in an open thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "args": { + "action": "edit" + }, + "expect": null + }, + { + "name": "edit in an archived thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "archived": true + }, + "args": { + "action": "edit" + }, + "expect": "THREAD_ARCHIVED" + }, + { + "name": "delete in an archived locked thread", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "archived": true, + "locked": true + }, + "args": { + "action": "delete" + }, + "expect": null + }, + { + "name": "timed out delete", + "fn": "threadWriteBlock", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "timedOut": true + }, + "args": { + "action": "delete" + }, + "expect": null + }, + { + "name": "member unarchives an unlocked thread", + "fn": "canUnarchive", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "thread": { + "archived": true + }, + "expect": null + }, + { + "name": "non-member cannot unarchive", + "fn": "canUnarchive", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "archived": true + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "creator who left unarchives an unlocked thread", + "fn": "canUnarchive", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "thread": { + "archived": true + }, + "expect": null + }, + { + "name": "member without send in threads unarchives an unlocked thread", + "fn": "canUnarchive", + "actor": { + "perms": ["VIEW_CHANNEL", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "thread": { + "archived": true + }, + "expect": null + }, + { + "name": "member cannot unarchive a locked thread", + "fn": "canUnarchive", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "thread": { + "archived": true, + "locked": true + }, + "expect": "THREAD_LOCKED" + }, + { + "name": "moderator unarchives a locked thread", + "fn": "canUnarchive", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "thread": { + "archived": true, + "locked": true + }, + "expect": null + }, + { + "name": "timed out member cannot unarchive", + "fn": "canUnarchive", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true, + "timedOut": true + }, + "thread": { + "archived": true + }, + "expect": "COMMUNICATION_DISABLED" + }, + { + "name": "thread owner renames", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "args": { + "patch": { + "name": "x" + } + }, + "expect": null + }, + { + "name": "other member cannot rename", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "args": { + "patch": { + "name": "x" + } + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "moderator renames", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "args": { + "patch": { + "name": "x" + } + }, + "expect": null + }, + { + "name": "thread owner changes auto archive", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "args": { + "patch": { + "auto_archive_duration": 60 + } + }, + "expect": null + }, + { + "name": "thread owner archives", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "args": { + "patch": { + "archived": true + } + }, + "expect": null + }, + { + "name": "member cannot archive", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "args": { + "patch": { + "archived": true + } + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "member unarchives through patch", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "thread": { + "archived": true + }, + "args": { + "patch": { + "archived": false + } + }, + "expect": null + }, + { + "name": "non-member cannot unarchive through patch", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "archived": true + }, + "args": { + "patch": { + "archived": false + } + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "creator who left unarchives through patch", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "thread": { + "archived": true + }, + "args": { + "patch": { + "archived": false + } + }, + "expect": null + }, + { + "name": "member without send in threads unarchives through patch", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "thread": { + "archived": true + }, + "args": { + "patch": { + "archived": false + } + }, + "expect": null + }, + { + "name": "member cannot unarchive a locked thread through patch", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "thread": { + "archived": true, + "locked": true + }, + "args": { + "patch": { + "archived": false + } + }, + "expect": "THREAD_LOCKED" + }, + { + "name": "renaming an archived thread", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "thread": { + "archived": true + }, + "args": { + "patch": { + "name": "x" + } + }, + "expect": "THREAD_ARCHIVED" + }, + { + "name": "renaming while unarchiving", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "thread": { + "archived": true + }, + "args": { + "patch": { + "name": "x", + "archived": false + } + }, + "expect": null + }, + { + "name": "thread owner locks", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "args": { + "patch": { + "locked": true + } + }, + "expect": null + }, + { + "name": "thread owner cannot unlock", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "thread": { + "locked": true + }, + "args": { + "patch": { + "locked": false + } + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "member cannot lock", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "args": { + "patch": { + "locked": true + } + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "moderator unlocks", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "thread": { + "locked": true + }, + "args": { + "patch": { + "locked": false + } + }, + "expect": null + }, + { + "name": "thread owner renames a locked thread", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "thread": { + "locked": true + }, + "args": { + "patch": { + "name": "x" + } + }, + "expect": "THREAD_LOCKED" + }, + { + "name": "thread owner changes slowmode", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "args": { + "patch": { + "rate_limit_per_user": 10 + } + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "moderator changes slowmode", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "args": { + "patch": { + "rate_limit_per_user": 10 + } + }, + "expect": null + }, + { + "name": "thread owner changes invitable", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "thread": { + "type": 12 + }, + "args": { + "patch": { + "invitable": false + } + }, + "expect": null + }, + { + "name": "member cannot change invitable", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "thread": { + "type": 12 + }, + "args": { + "patch": { + "invitable": false + } + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "thread owner cannot pin", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "args": { + "patch": { + "flags": 2 + }, + "currentFlags": 0 + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "unchanged flags need nothing", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "args": { + "patch": { + "flags": 2 + }, + "currentFlags": 2 + }, + "expect": null + }, + { + "name": "moderator pins", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "args": { + "patch": { + "flags": 2 + }, + "currentFlags": 0 + }, + "expect": null + }, + { + "name": "timed out thread owner renames", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true, + "timedOut": true + }, + "args": { + "patch": { + "name": "x" + } + }, + "expect": "COMMUNICATION_DISABLED" + }, + { + "name": "empty patch on an archived thread", + "fn": "threadPatchRequirement", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "archived": true + }, + "args": { + "patch": {} + }, + "expect": null + }, + { + "name": "thread owner sets plain tags", + "fn": "canSetTags", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "args": { + "touchesModeratedTag": false + }, + "expect": null + }, + { + "name": "thread owner cannot set moderated tags", + "fn": "canSetTags", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "args": { + "touchesModeratedTag": true + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "moderator sets moderated tags", + "fn": "canSetTags", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "args": { + "touchesModeratedTag": true + }, + "expect": null + }, + { + "name": "member cannot set tags", + "fn": "canSetTags", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "args": { + "touchesModeratedTag": false + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "join a public thread", + "fn": "canJoinThread", + "actor": { + "perms": ["VIEW_CHANNEL"] + }, + "expect": null + }, + { + "name": "join an archived thread", + "fn": "canJoinThread", + "actor": { + "perms": ["VIEW_CHANNEL"] + }, + "thread": { + "archived": true + }, + "expect": "THREAD_ARCHIVED" + }, + { + "name": "join a locked thread", + "fn": "canJoinThread", + "actor": { + "perms": ["VIEW_CHANNEL"] + }, + "thread": { + "locked": true + }, + "expect": "THREAD_LOCKED" + }, + { + "name": "moderator joins a locked thread", + "fn": "canJoinThread", + "actor": { + "perms": ["VIEW_CHANNEL", "MANAGE_THREADS"] + }, + "thread": { + "locked": true + }, + "expect": null + }, + { + "name": "join without parent view", + "fn": "canJoinThread", + "actor": { + "perms": [] + }, + "expect": "MISSING_ACCESS" + }, + { + "name": "timed out user joins", + "fn": "canJoinThread", + "actor": { + "perms": ["VIEW_CHANNEL"], + "timedOut": true + }, + "expect": null + }, + { + "name": "join an announcement thread", + "fn": "canJoinThread", + "actor": { + "perms": ["VIEW_CHANNEL"] + }, + "thread": { + "type": 10 + }, + "expect": null + }, + { + "name": "member adds to a public thread", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "args": { + "target": { + "canViewParent": true, + "isModerator": false + } + }, + "expect": null + }, + { + "name": "add without send in threads", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES"] + }, + "args": { + "target": { + "canViewParent": true, + "isModerator": false + } + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "add to an archived thread", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "archived": true + }, + "args": { + "target": { + "canViewParent": true, + "isModerator": false + } + }, + "expect": "THREAD_ARCHIVED" + }, + { + "name": "add to a locked thread", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "locked": true + }, + "args": { + "target": { + "canViewParent": true, + "isModerator": false + } + }, + "expect": "THREAD_LOCKED" + }, + { + "name": "add to an invitable private thread", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "type": 12, + "invitable": true + }, + "args": { + "target": { + "canViewParent": true, + "isModerator": false + } + }, + "expect": null + }, + { + "name": "add to a non-invitable private thread", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "type": 12, + "invitable": false + }, + "args": { + "target": { + "canViewParent": true, + "isModerator": false + } + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "moderator adds to a non-invitable private thread", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "thread": { + "type": 12, + "invitable": false + }, + "args": { + "target": { + "canViewParent": true, + "isModerator": false + } + }, + "expect": null + }, + { + "name": "add a moderator to a non-invitable private thread", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "type": 12, + "invitable": false + }, + "args": { + "target": { + "canViewParent": true, + "isModerator": true + } + }, + "expect": null + }, + { + "name": "add a target without parent view", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "args": { + "target": { + "canViewParent": false, + "isModerator": false + } + }, + "expect": "MISSING_ACCESS" + }, + { + "name": "timed out add", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "timedOut": true + }, + "args": { + "target": { + "canViewParent": true, + "isModerator": false + } + }, + "expect": "COMMUNICATION_DISABLED" + }, + { + "name": "add to an announcement thread ignores invitable", + "fn": "canAddThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "thread": { + "type": 10, + "invitable": false + }, + "args": { + "target": { + "canViewParent": true, + "isModerator": false + } + }, + "expect": null + }, + { + "name": "moderator removes", + "fn": "canRemoveThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "expect": null + }, + { + "name": "private thread owner removes", + "fn": "canRemoveThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "thread": { + "type": 12 + }, + "expect": null + }, + { + "name": "public thread owner cannot remove", + "fn": "canRemoveThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "remove from an archived thread", + "fn": "canRemoveThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "thread": { + "archived": true + }, + "expect": "THREAD_ARCHIVED" + }, + { + "name": "timed out moderator removes", + "fn": "canRemoveThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"], + "timedOut": true + }, + "expect": "COMMUNICATION_DISABLED" + }, + { + "name": "announcement thread owner cannot remove", + "fn": "canRemoveThreadMember", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "thread": { + "type": 10 + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "member leaves", + "fn": "canLeave", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "expect": null + }, + { + "name": "non-member leaves", + "fn": "canLeave", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"] + }, + "expect": "UNKNOWN_THREAD_MEMBER" + }, + { + "name": "leave an archived thread", + "fn": "canLeave", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true + }, + "thread": { + "archived": true + }, + "expect": "THREAD_ARCHIVED" + }, + { + "name": "timed out member leaves", + "fn": "canLeave", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isMember": true, + "timedOut": true + }, + "expect": null + }, + { + "name": "moderator deletes", + "fn": "canDeleteThread", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "expect": null + }, + { + "name": "thread owner cannot delete", + "fn": "canDeleteThread", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY"], + "isThreadOwner": true + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "timed out moderator deletes", + "fn": "canDeleteThread", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES_IN_THREADS", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"], + "timedOut": true + }, + "expect": "COMMUNICATION_DISABLED" + }, + { + "name": "administrator deletes", + "fn": "canDeleteThread", + "actor": { + "perms": ["ADMINISTRATOR"] + }, + "expect": null + }, + { + "name": "create from a message", + "fn": "canCreateThread", + "actor": { + "perms": ["VIEW_CHANNEL", "CREATE_PUBLIC_THREADS", "READ_MESSAGE_HISTORY"] + }, + "args": { + "kind": "from_message" + }, + "expect": null + }, + { + "name": "create from a message without history", + "fn": "canCreateThread", + "actor": { + "perms": ["VIEW_CHANNEL", "CREATE_PUBLIC_THREADS"] + }, + "args": { + "kind": "from_message" + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "create a public thread", + "fn": "canCreateThread", + "actor": { + "perms": ["VIEW_CHANNEL", "CREATE_PUBLIC_THREADS"] + }, + "args": { + "kind": "public" + }, + "expect": null + }, + { + "name": "create a public thread without the bit", + "fn": "canCreateThread", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES"] + }, + "args": { + "kind": "public" + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "create a private thread", + "fn": "canCreateThread", + "actor": { + "perms": ["VIEW_CHANNEL", "CREATE_PRIVATE_THREADS"] + }, + "args": { + "kind": "private" + }, + "expect": null + }, + { + "name": "create a private thread with only the public bit", + "fn": "canCreateThread", + "actor": { + "perms": ["VIEW_CHANNEL", "CREATE_PUBLIC_THREADS"] + }, + "args": { + "kind": "private" + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "forum post with send messages", + "fn": "canCreateThread", + "actor": { + "perms": ["VIEW_CHANNEL", "SEND_MESSAGES"] + }, + "args": { + "kind": "forum_post" + }, + "expect": null + }, + { + "name": "forum post ignores create public threads", + "fn": "canCreateThread", + "actor": { + "perms": ["VIEW_CHANNEL", "CREATE_PUBLIC_THREADS", "SEND_MESSAGES_IN_THREADS"] + }, + "args": { + "kind": "forum_post" + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "timed out create", + "fn": "canCreateThread", + "actor": { + "perms": ["VIEW_CHANNEL", "CREATE_PUBLIC_THREADS"], + "timedOut": true + }, + "args": { + "kind": "public" + }, + "expect": "COMMUNICATION_DISABLED" + }, + { + "name": "create without parent view", + "fn": "canCreateThread", + "actor": { + "perms": ["CREATE_PUBLIC_THREADS"] + }, + "args": { + "kind": "public" + }, + "expect": "MISSING_ACCESS" + }, + { + "name": "administrator creates a private thread", + "fn": "canCreateThread", + "actor": { + "perms": ["ADMINISTRATOR"] + }, + "args": { + "kind": "private" + }, + "expect": null + }, + { + "name": "public archived list", + "fn": "canListArchivedThreads", + "actor": { + "perms": ["VIEW_CHANNEL", "READ_MESSAGE_HISTORY"] + }, + "args": { + "privateThreads": false + }, + "expect": null + }, + { + "name": "public archived list without history", + "fn": "canListArchivedThreads", + "actor": { + "perms": ["VIEW_CHANNEL"] + }, + "args": { + "privateThreads": false + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "private archived list for members", + "fn": "canListArchivedThreads", + "actor": { + "perms": ["VIEW_CHANNEL", "READ_MESSAGE_HISTORY"] + }, + "args": { + "privateThreads": true + }, + "expect": "MISSING_PERMISSIONS" + }, + { + "name": "private archived list for moderators", + "fn": "canListArchivedThreads", + "actor": { + "perms": ["VIEW_CHANNEL", "READ_MESSAGE_HISTORY", "MANAGE_THREADS"] + }, + "args": { + "privateThreads": true + }, + "expect": null + } +] diff --git a/packages/constants/src/ThreadPermissionUtils.test.ts b/packages/constants/src/ThreadPermissionUtils.test.ts new file mode 100644 index 000000000..f47cbadcc --- /dev/null +++ b/packages/constants/src/ThreadPermissionUtils.test.ts @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import cases from '@fluxer/constants/src/ThreadPermissionCases.json' with {type: 'json'}; +import { + canAddThreadMember, + canCreateThread, + canDeleteThread, + canJoinThread, + canLeave, + canListArchivedThreads, + canRemoveThreadMember, + canSetTags, + canUnarchive, + canViewThread, + DEFAULT_THREAD_PERMISSIONS, + isThreadModerator, + messageSendPermissionFor, + THREAD_AWARE_ALL_PERMISSIONS, + THREAD_PERMISSIONS, + type ThreadActorContext, + type ThreadCreateKind, + type ThreadPatch, + ThreadPermissionFlags, + type ThreadWriteAction, + threadPatchRequirement, + threadViewPermissions, + threadWriteBlock, + withImplicitThreadBits, +} from '@fluxer/constants/src/ThreadPermissionUtils'; +import {describe, expect, it} from 'vitest'; + +interface CaseRow { + name: string; + fn: string; + actor: { + perms: Array; + isOwner?: boolean; + timedOut?: boolean; + isThreadOwner?: boolean; + isMember?: boolean; + }; + thread?: {type?: number; archived?: boolean; locked?: boolean; invitable?: boolean}; + args?: { + action?: string; + patch?: ThreadPatch; + currentFlags?: number; + touchesModeratedTag?: boolean; + target?: {canViewParent: boolean; isModerator: boolean}; + kind?: string; + privateThreads?: boolean; + }; + expect: string | boolean | null; +} + +const PERMISSION_BITS: Record = {...Permissions, ...ThreadPermissionFlags}; + +function permissionsOf(names: Array): bigint { + return names.reduce((acc, name) => { + const bit = PERMISSION_BITS[name]; + if (bit === undefined) throw new Error(`unknown permission ${name}`); + return acc | bit; + }, 0n); +} + +function contextOf(row: CaseRow): ThreadActorContext { + return { + permissions: permissionsOf(row.actor.perms), + isOwner: row.actor.isOwner ?? false, + timedOut: row.actor.timedOut ?? false, + isThreadOwner: row.actor.isThreadOwner ?? false, + isMember: row.actor.isMember ?? false, + thread: { + type: row.thread?.type ?? ChannelTypes.PUBLIC_THREAD, + archived: row.thread?.archived ?? false, + locked: row.thread?.locked ?? false, + invitable: row.thread?.invitable ?? true, + }, + }; +} + +function evaluate(row: CaseRow): string | boolean | null { + const ctx = contextOf(row); + const args = row.args ?? {}; + switch (row.fn) { + case 'isThreadModerator': + return isThreadModerator(ctx.permissions, ctx); + case 'canViewThread': + return canViewThread(ctx); + case 'threadWriteBlock': + return threadWriteBlock(args.action as ThreadWriteAction, ctx); + case 'canUnarchive': + return canUnarchive(ctx); + case 'threadPatchRequirement': + return threadPatchRequirement(args.patch ?? {}, args.currentFlags ?? 0, ctx); + case 'canSetTags': + return canSetTags(ctx, {touchesModeratedTag: args.touchesModeratedTag ?? false}); + case 'canJoinThread': + return canJoinThread(ctx); + case 'canAddThreadMember': + return canAddThreadMember(ctx, args.target ?? {canViewParent: true, isModerator: false}); + case 'canRemoveThreadMember': + return canRemoveThreadMember(ctx); + case 'canLeave': + return canLeave(ctx); + case 'canDeleteThread': + return canDeleteThread(ctx); + case 'canCreateThread': + return canCreateThread(ctx, args.kind as ThreadCreateKind); + case 'canListArchivedThreads': + return canListArchivedThreads(ctx, {privateThreads: args.privateThreads ?? false}); + default: + throw new Error(`unknown case function ${row.fn}`); + } +} + +describe('thread permission case table', () => { + it.each((cases as Array).map((row) => [row.name, row] as const))('%s', (_name, row) => { + expect(evaluate(row)).toBe(row.expect); + }); + + it('has unique case names', () => { + const names = (cases as Array).map((row) => row.name); + expect(new Set(names).size).toBe(names.length); + }); +}); + +describe('thread permission bits', () => { + it('uses the expected bit positions', () => { + expect(ThreadPermissionFlags.MANAGE_THREADS).toBe(1n << 34n); + expect(ThreadPermissionFlags.CREATE_PUBLIC_THREADS).toBe(1n << 35n); + expect(ThreadPermissionFlags.CREATE_PRIVATE_THREADS).toBe(1n << 36n); + expect(ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS).toBe(1n << 38n); + }); + + it('stays outside the control permission set', () => { + expect(ALL_PERMISSIONS & THREAD_PERMISSIONS).toBe(0n); + expect(THREAD_AWARE_ALL_PERMISSIONS).toBe(ALL_PERMISSIONS | THREAD_PERMISSIONS); + }); + + it('defaults to everything except manage threads', () => { + expect(DEFAULT_THREAD_PERMISSIONS).toBe(THREAD_PERMISSIONS & ~ThreadPermissionFlags.MANAGE_THREADS); + }); + + it('grants every thread bit to administrators only', () => { + expect(withImplicitThreadBits(Permissions.ADMINISTRATOR) & THREAD_PERMISSIONS).toBe(THREAD_PERMISSIONS); + expect(withImplicitThreadBits(Permissions.MANAGE_CHANNELS)).toBe(Permissions.MANAGE_CHANNELS); + }); + + it('aliases send messages to send messages in threads', () => { + const parent = Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES; + expect(threadViewPermissions(parent) & Permissions.SEND_MESSAGES).toBe(0n); + const threadSender = Permissions.VIEW_CHANNEL | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS; + expect(threadViewPermissions(threadSender) & Permissions.SEND_MESSAGES).toBe(Permissions.SEND_MESSAGES); + expect(threadViewPermissions(Permissions.ADMINISTRATOR) & Permissions.SEND_MESSAGES).toBe( + Permissions.SEND_MESSAGES, + ); + }); + + it('picks the send bit by channel type', () => { + expect(messageSendPermissionFor(ChannelTypes.ANNOUNCEMENT_THREAD)).toBe( + ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS, + ); + expect(messageSendPermissionFor(ChannelTypes.PUBLIC_THREAD)).toBe(ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS); + expect(messageSendPermissionFor(ChannelTypes.PRIVATE_THREAD)).toBe(ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS); + expect(messageSendPermissionFor(ChannelTypes.GUILD_TEXT)).toBe(Permissions.SEND_MESSAGES); + expect(messageSendPermissionFor(ChannelTypes.GUILD_ANNOUNCEMENT)).toBe(Permissions.SEND_MESSAGES); + expect(messageSendPermissionFor(ChannelTypes.GUILD_FORUM)).toBe(Permissions.SEND_MESSAGES); + }); +}); diff --git a/packages/constants/src/ThreadPermissionUtils.ts b/packages/constants/src/ThreadPermissionUtils.ts new file mode 100644 index 000000000..6c106a251 --- /dev/null +++ b/packages/constants/src/ThreadPermissionUtils.ts @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; + +export const ThreadPermissionFlags = { + MANAGE_THREADS: 1n << 34n, + CREATE_PUBLIC_THREADS: 1n << 35n, + CREATE_PRIVATE_THREADS: 1n << 36n, + SEND_MESSAGES_IN_THREADS: 1n << 38n, +} as const; + +export const THREAD_PERMISSIONS = Object.values(ThreadPermissionFlags).reduce((acc, p) => acc | p, 0n); +export const DEFAULT_THREAD_PERMISSIONS = + ThreadPermissionFlags.CREATE_PUBLIC_THREADS | + ThreadPermissionFlags.CREATE_PRIVATE_THREADS | + ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS; +export const THREAD_AWARE_ALL_PERMISSIONS = ALL_PERMISSIONS | THREAD_PERMISSIONS; + +export type ThreadDenial = + | typeof APIErrorCodes.MISSING_ACCESS + | typeof APIErrorCodes.MISSING_PERMISSIONS + | typeof APIErrorCodes.COMMUNICATION_DISABLED + | typeof APIErrorCodes.THREAD_ARCHIVED + | typeof APIErrorCodes.THREAD_LOCKED + | typeof APIErrorCodes.UNKNOWN_THREAD_MEMBER; + +export interface ThreadActor { + permissions: bigint; + isOwner: boolean; + timedOut: boolean; +} + +export interface ThreadFacts { + type: number; + archived: boolean; + locked: boolean; + invitable: boolean; +} + +export interface ThreadActorContext extends ThreadActor { + thread: ThreadFacts; + isThreadOwner: boolean; + isMember: boolean; +} + +export type ThreadWriteAction = 'send' | 'react' | 'pin' | 'edit' | 'delete'; + +export interface ThreadPatch { + name?: unknown; + archived?: boolean; + auto_archive_duration?: unknown; + locked?: boolean; + invitable?: unknown; + rate_limit_per_user?: unknown; + flags?: number; + applied_tags?: unknown; +} + +export type ThreadCreateKind = 'from_message' | 'public' | 'private' | 'forum_post'; + +function has(perms: bigint, bit: bigint): boolean { + return (perms & bit) === bit; +} + +export function withImplicitThreadBits(perms: bigint): bigint { + return has(perms, Permissions.ADMINISTRATOR) ? perms | THREAD_AWARE_ALL_PERMISSIONS : perms; +} + +export function isThreadModerator(perms: bigint, {isOwner, timedOut}: {isOwner: boolean; timedOut: boolean}): boolean { + if (isOwner || has(perms, Permissions.ADMINISTRATOR)) return true; + return !timedOut && has(perms, ThreadPermissionFlags.MANAGE_THREADS); +} + +function isCommunicationBlocked(actor: ThreadActor): boolean { + return actor.timedOut && !actor.isOwner && !has(actor.permissions, Permissions.ADMINISTRATOR); +} + +function isModerator(actor: ThreadActor): boolean { + return isThreadModerator(withImplicitThreadBits(actor.permissions), actor); +} + +export function threadViewPermissions(parentPerms: bigint): bigint { + const perms = withImplicitThreadBits(parentPerms); + const withoutSend = perms & ~Permissions.SEND_MESSAGES; + return has(perms, ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS) + ? withoutSend | Permissions.SEND_MESSAGES + : withoutSend; +} + +export function messageSendPermissionFor(type: number): bigint { + return THREAD_CHANNEL_TYPES.has(type) ? ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS : Permissions.SEND_MESSAGES; +} + +export function canViewThread(ctx: ThreadActorContext): ThreadDenial | null { + const perms = withImplicitThreadBits(ctx.permissions); + if (!ctx.isOwner && !has(perms, Permissions.VIEW_CHANNEL)) return APIErrorCodes.MISSING_ACCESS; + if (ctx.thread.type === ChannelTypes.PRIVATE_THREAD && !ctx.isMember && !isModerator(ctx)) { + return APIErrorCodes.MISSING_ACCESS; + } + return null; +} + +export function threadWriteBlock(action: ThreadWriteAction, ctx: ThreadActorContext): ThreadDenial | null { + if (action === 'delete') return null; + if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED; + const moderator = isModerator(ctx); + if (action === 'send') { + return ctx.thread.locked && !moderator ? APIErrorCodes.THREAD_LOCKED : null; + } + if (ctx.thread.archived) return APIErrorCodes.THREAD_ARCHIVED; + if (ctx.thread.locked && !moderator) return APIErrorCodes.THREAD_LOCKED; + return null; +} + +export function canUnarchive(ctx: ThreadActorContext): ThreadDenial | null { + if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED; + if (isModerator(ctx)) return null; + if (ctx.thread.locked) return APIErrorCodes.THREAD_LOCKED; + return ctx.isMember || ctx.isThreadOwner ? null : APIErrorCodes.MISSING_PERMISSIONS; +} + +export function threadPatchRequirement( + patch: ThreadPatch, + currentFlags: number, + ctx: ThreadActorContext, +): ThreadDenial | null { + if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED; + const moderator = isModerator(ctx); + const ownerOrModerator = moderator || ctx.isThreadOwner; + if (patch.archived === false && ctx.thread.archived) { + const unarchive = canUnarchive(ctx); + if (unarchive !== null) return unarchive; + } + const staysArchived = ctx.thread.archived && patch.archived !== false; + const touchesOtherFields = + patch.name !== undefined || + patch.auto_archive_duration !== undefined || + patch.locked !== undefined || + patch.invitable !== undefined || + patch.rate_limit_per_user !== undefined || + (patch.flags !== undefined && patch.flags !== currentFlags) || + patch.applied_tags !== undefined; + if (staysArchived && touchesOtherFields) return APIErrorCodes.THREAD_ARCHIVED; + if (patch.locked !== undefined && patch.locked !== ctx.thread.locked && !moderator) { + if (!(patch.locked && ctx.isThreadOwner)) return APIErrorCodes.MISSING_PERMISSIONS; + } + if (ctx.thread.locked && !moderator) return APIErrorCodes.THREAD_LOCKED; + if ((patch.name !== undefined || patch.auto_archive_duration !== undefined) && !ownerOrModerator) { + return APIErrorCodes.MISSING_PERMISSIONS; + } + if (patch.archived === true && !ctx.thread.archived && !ownerOrModerator) return APIErrorCodes.MISSING_PERMISSIONS; + if (patch.invitable !== undefined && !ownerOrModerator) return APIErrorCodes.MISSING_PERMISSIONS; + if (patch.rate_limit_per_user !== undefined && !moderator) return APIErrorCodes.MISSING_PERMISSIONS; + if (patch.flags !== undefined && patch.flags !== currentFlags && !moderator) return APIErrorCodes.MISSING_PERMISSIONS; + return null; +} + +export function canSetTags( + ctx: ThreadActorContext, + {touchesModeratedTag}: {touchesModeratedTag: boolean}, +): ThreadDenial | null { + if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED; + const moderator = isModerator(ctx); + if (!moderator && !ctx.isThreadOwner) return APIErrorCodes.MISSING_PERMISSIONS; + if (touchesModeratedTag && !moderator) return APIErrorCodes.MISSING_PERMISSIONS; + return null; +} + +export function canJoinThread(ctx: ThreadActorContext): ThreadDenial | null { + const view = canViewThread(ctx); + if (view !== null) return view; + if (ctx.thread.archived) return APIErrorCodes.THREAD_ARCHIVED; + if (ctx.thread.locked && !isModerator(ctx)) return APIErrorCodes.THREAD_LOCKED; + return null; +} + +export function canAddThreadMember( + ctx: ThreadActorContext, + target: {canViewParent: boolean; isModerator: boolean}, +): ThreadDenial | null { + if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED; + if (ctx.thread.archived) return APIErrorCodes.THREAD_ARCHIVED; + const moderator = isModerator(ctx); + if (ctx.thread.locked && !moderator) return APIErrorCodes.THREAD_LOCKED; + if (!has(threadViewPermissions(ctx.permissions), Permissions.SEND_MESSAGES)) return APIErrorCodes.MISSING_PERMISSIONS; + if (ctx.thread.type === ChannelTypes.PRIVATE_THREAD && !ctx.thread.invitable && !moderator && !target.isModerator) { + return APIErrorCodes.MISSING_PERMISSIONS; + } + if (!target.canViewParent) return APIErrorCodes.MISSING_ACCESS; + return null; +} + +export function canRemoveThreadMember(ctx: ThreadActorContext): ThreadDenial | null { + if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED; + if (ctx.thread.archived) return APIErrorCodes.THREAD_ARCHIVED; + if (isModerator(ctx)) return null; + if (ctx.thread.type === ChannelTypes.PRIVATE_THREAD && ctx.isThreadOwner) return null; + return APIErrorCodes.MISSING_PERMISSIONS; +} + +export function canLeave(ctx: ThreadActorContext): ThreadDenial | null { + if (!ctx.isMember) return APIErrorCodes.UNKNOWN_THREAD_MEMBER; + if (ctx.thread.archived) return APIErrorCodes.THREAD_ARCHIVED; + return null; +} + +export function canDeleteThread(ctx: ThreadActor): ThreadDenial | null { + if (isCommunicationBlocked(ctx)) return APIErrorCodes.COMMUNICATION_DISABLED; + return isModerator(ctx) ? null : APIErrorCodes.MISSING_PERMISSIONS; +} + +export function canCreateThread(actor: ThreadActor, kind: ThreadCreateKind): ThreadDenial | null { + const perms = withImplicitThreadBits(actor.permissions); + if (actor.isOwner) return null; + if (!has(perms, Permissions.VIEW_CHANNEL)) return APIErrorCodes.MISSING_ACCESS; + if (isCommunicationBlocked(actor)) return APIErrorCodes.COMMUNICATION_DISABLED; + switch (kind) { + case 'forum_post': + return has(perms, Permissions.SEND_MESSAGES) ? null : APIErrorCodes.MISSING_PERMISSIONS; + case 'from_message': + return has(perms, ThreadPermissionFlags.CREATE_PUBLIC_THREADS) && has(perms, Permissions.READ_MESSAGE_HISTORY) + ? null + : APIErrorCodes.MISSING_PERMISSIONS; + case 'public': + return has(perms, ThreadPermissionFlags.CREATE_PUBLIC_THREADS) ? null : APIErrorCodes.MISSING_PERMISSIONS; + case 'private': + return has(perms, ThreadPermissionFlags.CREATE_PRIVATE_THREADS) ? null : APIErrorCodes.MISSING_PERMISSIONS; + } +} + +export function canListArchivedThreads( + actor: ThreadActor, + {privateThreads}: {privateThreads: boolean}, +): ThreadDenial | null { + const perms = withImplicitThreadBits(actor.permissions); + if (!actor.isOwner && !has(perms, Permissions.READ_MESSAGE_HISTORY)) return APIErrorCodes.MISSING_PERMISSIONS; + if (privateThreads && !isModerator(actor)) return APIErrorCodes.MISSING_PERMISSIONS; + return null; +} diff --git a/packages/errors/src/__tests__/AnnouncementErrors.test.ts b/packages/errors/src/__tests__/AnnouncementErrors.test.ts index fde05221d..da58ef08c 100644 --- a/packages/errors/src/__tests__/AnnouncementErrors.test.ts +++ b/packages/errors/src/__tests__/AnnouncementErrors.test.ts @@ -5,6 +5,7 @@ import {HttpStatus} from '@fluxer/constants/src/HttpConstants'; import {AnnouncementChannelRequiredError} from '@fluxer/errors/src/domains/channel/AnnouncementChannelRequiredError'; import {ChannelAlreadyFollowedError} from '@fluxer/errors/src/domains/channel/ChannelAlreadyFollowedError'; import {ChannelHasFollowedChannelsError} from '@fluxer/errors/src/domains/channel/ChannelHasFollowedChannelsError'; +import {ChannelHasThreadsError} from '@fluxer/errors/src/domains/channel/ChannelHasThreadsError'; import {ChannelTypeConversionNotSupportedError} from '@fluxer/errors/src/domains/channel/ChannelTypeConversionNotSupportedError'; import {FollowTargetContentWarningRequiredError} from '@fluxer/errors/src/domains/channel/FollowTargetContentWarningRequiredError'; import {FollowTargetNotAgeRestrictedError} from '@fluxer/errors/src/domains/channel/FollowTargetNotAgeRestrictedError'; @@ -19,6 +20,7 @@ describe.each([ [APIErrorCodes.ANNOUNCEMENT_CHANNEL_REQUIRED, AnnouncementChannelRequiredError], [APIErrorCodes.CHANNEL_ALREADY_FOLLOWED, ChannelAlreadyFollowedError], [APIErrorCodes.CHANNEL_HAS_FOLLOWED_CHANNELS, ChannelHasFollowedChannelsError], + [APIErrorCodes.CHANNEL_HAS_THREADS, ChannelHasThreadsError], [APIErrorCodes.CHANNEL_TYPE_CONVERSION_NOT_SUPPORTED, ChannelTypeConversionNotSupportedError], [APIErrorCodes.FOLLOW_TARGET_CONTENT_WARNING_REQUIRED, FollowTargetContentWarningRequiredError], [APIErrorCodes.FOLLOW_TARGET_NOT_AGE_RESTRICTED, FollowTargetNotAgeRestrictedError], diff --git a/packages/errors/src/__tests__/DomainErrors.test.ts b/packages/errors/src/__tests__/DomainErrors.test.ts index f3f1df5af..5d78144d4 100644 --- a/packages/errors/src/__tests__/DomainErrors.test.ts +++ b/packages/errors/src/__tests__/DomainErrors.test.ts @@ -6,8 +6,13 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {AccountIdentityLockedError} from '@fluxer/errors/src/domains/auth/AccountIdentityLockedError'; import {EmailUnavailableOnInstanceError} from '@fluxer/errors/src/domains/auth/EmailUnavailableOnInstanceError'; import {UsernameSignInOnlyError} from '@fluxer/errors/src/domains/auth/UsernameSignInOnlyError'; +import {MaxActiveThreadsError} from '@fluxer/errors/src/domains/channel/MaxActiveThreadsError'; +import {SearchIndexNotReadyError} from '@fluxer/errors/src/domains/channel/SearchIndexNotReadyError'; +import {ThreadArchivedError} from '@fluxer/errors/src/domains/channel/ThreadArchivedError'; +import {ThreadLockedError} from '@fluxer/errors/src/domains/channel/ThreadLockedError'; import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError'; +import {UnknownThreadMemberError} from '@fluxer/errors/src/domains/channel/UnknownThreadMemberError'; import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError'; import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError'; @@ -106,6 +111,9 @@ describe.each([ FluxerError, HttpStatus.UNAUTHORIZED, ], + [APIErrorCodes.THREAD_ARCHIVED, ThreadArchivedError, BadRequestError, HttpStatus.BAD_REQUEST], + [APIErrorCodes.THREAD_LOCKED, ThreadLockedError, BadRequestError, HttpStatus.BAD_REQUEST], + [APIErrorCodes.UNKNOWN_THREAD_MEMBER, UnknownThreadMemberError, NotFoundError, HttpStatus.NOT_FOUND], ] as const)('%s', (code, ErrorClass, BaseClass, status) => { it('preserves the domain inheritance and response contract', async () => { const error = new ErrorClass(); @@ -122,6 +130,30 @@ describe.each([ }); }); +describe('thread limit and index errors', () => { + it('reports the thread limit in data and message variables', async () => { + const error = new MaxActiveThreadsError(1000); + expect(error.status).toBe(HttpStatus.BAD_REQUEST); + expect(error.messageVariables).toEqual({count: 1000}); + expect(await error.getResponse().json()).toEqual({ + code: APIErrorCodes.MAX_ACTIVE_THREADS, + message: APIErrorCodes.MAX_ACTIVE_THREADS, + max_active_threads: 1000, + }); + }); + + it('answers an unready search index with 202 and a retry hint', async () => { + const response = new SearchIndexNotReadyError(2).getResponse(); + expect(response.status).toBe(202); + expect(await response.json()).toEqual({ + code: APIErrorCodes.SEARCH_INDEX_NOT_READY, + message: APIErrorCodes.SEARCH_INDEX_NOT_READY, + documents_indexed: 0, + retry_after: 2, + }); + }); +}); + describe('InputValidationError', () => { const errors = [ {path: 'email', message: 'Invalid email'}, diff --git a/packages/errors/src/domains/channel/ChannelHasThreadsError.ts b/packages/errors/src/domains/channel/ChannelHasThreadsError.ts new file mode 100644 index 000000000..9e84c6e44 --- /dev/null +++ b/packages/errors/src/domains/channel/ChannelHasThreadsError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class ChannelHasThreadsError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.CHANNEL_HAS_THREADS, + }); + } +} diff --git a/packages/errors/src/domains/channel/ForumTagNamesMustBeUniqueError.ts b/packages/errors/src/domains/channel/ForumTagNamesMustBeUniqueError.ts new file mode 100644 index 000000000..30858914d --- /dev/null +++ b/packages/errors/src/domains/channel/ForumTagNamesMustBeUniqueError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class ForumTagNamesMustBeUniqueError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.FORUM_TAG_NAMES_MUST_BE_UNIQUE, + }); + } +} diff --git a/packages/errors/src/domains/channel/ForumTagRequiredError.ts b/packages/errors/src/domains/channel/ForumTagRequiredError.ts new file mode 100644 index 000000000..ed7df380d --- /dev/null +++ b/packages/errors/src/domains/channel/ForumTagRequiredError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class ForumTagRequiredError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.FORUM_TAG_REQUIRED, + }); + } +} diff --git a/packages/errors/src/domains/channel/HideMediaDownloadOptionMediaOnlyError.ts b/packages/errors/src/domains/channel/HideMediaDownloadOptionMediaOnlyError.ts new file mode 100644 index 000000000..e17e39a7a --- /dev/null +++ b/packages/errors/src/domains/channel/HideMediaDownloadOptionMediaOnlyError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class HideMediaDownloadOptionMediaOnlyError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY, + }); + } +} diff --git a/packages/errors/src/domains/channel/InvalidThreadNotificationSettingsError.ts b/packages/errors/src/domains/channel/InvalidThreadNotificationSettingsError.ts new file mode 100644 index 000000000..29f879e3a --- /dev/null +++ b/packages/errors/src/domains/channel/InvalidThreadNotificationSettingsError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class InvalidThreadNotificationSettingsError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.INVALID_THREAD_NOTIFICATION_SETTINGS, + }); + } +} diff --git a/packages/errors/src/domains/channel/MaxActiveThreadsError.ts b/packages/errors/src/domains/channel/MaxActiveThreadsError.ts new file mode 100644 index 000000000..6b6190c88 --- /dev/null +++ b/packages/errors/src/domains/channel/MaxActiveThreadsError.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class MaxActiveThreadsError extends BadRequestError { + constructor(limit: number) { + super({ + code: APIErrorCodes.MAX_ACTIVE_THREADS, + messageVariables: {count: limit}, + data: { + max_active_threads: limit, + }, + }); + } +} diff --git a/packages/errors/src/domains/channel/MaxForumTagsError.ts b/packages/errors/src/domains/channel/MaxForumTagsError.ts new file mode 100644 index 000000000..da004911e --- /dev/null +++ b/packages/errors/src/domains/channel/MaxForumTagsError.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class MaxForumTagsError extends BadRequestError { + constructor(limit: number) { + super({ + code: APIErrorCodes.MAX_FORUM_TAGS, + messageVariables: {count: limit}, + data: { + max_forum_tags: limit, + }, + }); + } +} diff --git a/packages/errors/src/domains/channel/MaxPinnedThreadsInForumError.ts b/packages/errors/src/domains/channel/MaxPinnedThreadsInForumError.ts new file mode 100644 index 000000000..d83640f2e --- /dev/null +++ b/packages/errors/src/domains/channel/MaxPinnedThreadsInForumError.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class MaxPinnedThreadsInForumError extends BadRequestError { + constructor(limit: number) { + super({ + code: APIErrorCodes.MAX_PINNED_THREADS_IN_FORUM, + messageVariables: {count: limit}, + data: { + max_pinned_threads: limit, + }, + }); + } +} diff --git a/packages/errors/src/domains/channel/MaxThreadMembersError.ts b/packages/errors/src/domains/channel/MaxThreadMembersError.ts new file mode 100644 index 000000000..ecb3a3e67 --- /dev/null +++ b/packages/errors/src/domains/channel/MaxThreadMembersError.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class MaxThreadMembersError extends BadRequestError { + constructor(limit: number) { + super({ + code: APIErrorCodes.MAX_THREAD_MEMBERS, + messageVariables: {count: limit}, + data: { + max_thread_members: limit, + }, + }); + } +} diff --git a/packages/errors/src/domains/channel/NoTagsAvailableToNonModeratorsError.ts b/packages/errors/src/domains/channel/NoTagsAvailableToNonModeratorsError.ts new file mode 100644 index 000000000..ff606e0f8 --- /dev/null +++ b/packages/errors/src/domains/channel/NoTagsAvailableToNonModeratorsError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class NoTagsAvailableToNonModeratorsError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.NO_TAGS_AVAILABLE_TO_NON_MODERATORS, + }); + } +} diff --git a/packages/errors/src/domains/channel/SearchIndexNotReadyError.ts b/packages/errors/src/domains/channel/SearchIndexNotReadyError.ts new file mode 100644 index 000000000..c420a3a65 --- /dev/null +++ b/packages/errors/src/domains/channel/SearchIndexNotReadyError.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {FluxerError} from '@fluxer/errors/src/FluxerError'; + +export class SearchIndexNotReadyError extends FluxerError { + constructor(retryAfterSeconds: number) { + super({ + code: APIErrorCodes.SEARCH_INDEX_NOT_READY, + status: 202, + data: { + documents_indexed: 0, + retry_after: retryAfterSeconds, + }, + }); + } +} diff --git a/packages/errors/src/domains/channel/ThreadAlreadyCreatedForMessageError.ts b/packages/errors/src/domains/channel/ThreadAlreadyCreatedForMessageError.ts new file mode 100644 index 000000000..6191771c3 --- /dev/null +++ b/packages/errors/src/domains/channel/ThreadAlreadyCreatedForMessageError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class ThreadAlreadyCreatedForMessageError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.THREAD_ALREADY_CREATED_FOR_MESSAGE, + }); + } +} diff --git a/packages/errors/src/domains/channel/ThreadArchivedError.ts b/packages/errors/src/domains/channel/ThreadArchivedError.ts new file mode 100644 index 000000000..f71b077cc --- /dev/null +++ b/packages/errors/src/domains/channel/ThreadArchivedError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class ThreadArchivedError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.THREAD_ARCHIVED, + }); + } +} diff --git a/packages/errors/src/domains/channel/ThreadLockedError.ts b/packages/errors/src/domains/channel/ThreadLockedError.ts new file mode 100644 index 000000000..a4778d363 --- /dev/null +++ b/packages/errors/src/domains/channel/ThreadLockedError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class ThreadLockedError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.THREAD_LOCKED, + }); + } +} diff --git a/packages/errors/src/domains/channel/UnknownForumTagError.ts b/packages/errors/src/domains/channel/UnknownForumTagError.ts new file mode 100644 index 000000000..d966c2f86 --- /dev/null +++ b/packages/errors/src/domains/channel/UnknownForumTagError.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError'; + +export class UnknownForumTagError extends NotFoundError { + constructor() { + super({code: APIErrorCodes.UNKNOWN_FORUM_TAG}); + } +} diff --git a/packages/errors/src/domains/channel/UnknownThreadMemberError.ts b/packages/errors/src/domains/channel/UnknownThreadMemberError.ts new file mode 100644 index 000000000..ece13d075 --- /dev/null +++ b/packages/errors/src/domains/channel/UnknownThreadMemberError.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError'; + +export class UnknownThreadMemberError extends NotFoundError { + constructor() { + super({code: APIErrorCodes.UNKNOWN_THREAD_MEMBER}); + } +} diff --git a/packages/errors/src/domains/webhook/WebhookForumTargetConflictError.ts b/packages/errors/src/domains/webhook/WebhookForumTargetConflictError.ts new file mode 100644 index 000000000..7e76f4b54 --- /dev/null +++ b/packages/errors/src/domains/webhook/WebhookForumTargetConflictError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class WebhookForumTargetConflictError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.WEBHOOK_FORUM_TARGET_CONFLICT, + }); + } +} diff --git a/packages/errors/src/domains/webhook/WebhookForumTargetRequiredError.ts b/packages/errors/src/domains/webhook/WebhookForumTargetRequiredError.ts new file mode 100644 index 000000000..39f651c81 --- /dev/null +++ b/packages/errors/src/domains/webhook/WebhookForumTargetRequiredError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class WebhookForumTargetRequiredError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.WEBHOOK_FORUM_TARGET_REQUIRED, + }); + } +} diff --git a/packages/errors/src/domains/webhook/WebhookServiceForumUnsupportedError.ts b/packages/errors/src/domains/webhook/WebhookServiceForumUnsupportedError.ts new file mode 100644 index 000000000..78b63c868 --- /dev/null +++ b/packages/errors/src/domains/webhook/WebhookServiceForumUnsupportedError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class WebhookServiceForumUnsupportedError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.WEBHOOK_SERVICE_FORUM_UNSUPPORTED, + }); + } +} diff --git a/packages/errors/src/domains/webhook/WebhookThreadNameRequiresForumError.ts b/packages/errors/src/domains/webhook/WebhookThreadNameRequiresForumError.ts new file mode 100644 index 000000000..0a5db9a93 --- /dev/null +++ b/packages/errors/src/domains/webhook/WebhookThreadNameRequiresForumError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class WebhookThreadNameRequiresForumError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.WEBHOOK_THREAD_NAME_REQUIRES_FORUM, + }); + } +} diff --git a/packages/errors/src/i18n/ErrorCodeMappings.ts b/packages/errors/src/i18n/ErrorCodeMappings.ts index 2e87c535a..bb1b5ea84 100644 --- a/packages/errors/src/i18n/ErrorCodeMappings.ts +++ b/packages/errors/src/i18n/ErrorCodeMappings.ts @@ -22,6 +22,7 @@ export const ErrorCodeToI18nKey = { [APIErrorCodes.ALREADY_FRIENDS]: 'friends_and_dms.already_friends', [APIErrorCodes.CHANNEL_ALREADY_FOLLOWED]: 'channels_and_guilds.channel_already_followed', [APIErrorCodes.CHANNEL_HAS_FOLLOWED_CHANNELS]: 'channels_and_guilds.channel_has_followed_channels', + [APIErrorCodes.CHANNEL_HAS_THREADS]: 'threads.channel_has_threads', [APIErrorCodes.CHANNEL_TYPE_CONVERSION_NOT_SUPPORTED]: 'channels_and_guilds.channel_type_conversion_not_supported', [APIErrorCodes.DIRECT_MESSAGES_DISABLED]: 'friends_and_dms.direct_messages_disabled', [APIErrorCodes.NEW_CONVERSATIONS_LIMITED]: 'friends_and_dms.new_conversations_limited', @@ -314,6 +315,25 @@ export const ErrorCodeToI18nKey = { [APIErrorCodes.UNKNOWN_WEBAUTHN_CREDENTIAL]: 'mfa_and_passkeys.unknown_webauthn_credential', [APIErrorCodes.UNKNOWN_APPLICATION]: 'auth_and_oauth.unknown_application', [APIErrorCodes.UNKNOWN_WEBHOOK]: 'webhooks.unknown_webhook', + [APIErrorCodes.THREAD_ARCHIVED]: 'threads.thread_archived', + [APIErrorCodes.THREAD_LOCKED]: 'threads.thread_locked', + [APIErrorCodes.THREAD_ALREADY_CREATED_FOR_MESSAGE]: 'threads.thread_already_created_for_message', + [APIErrorCodes.MAX_ACTIVE_THREADS]: 'threads.max_active_threads_reached', + [APIErrorCodes.MAX_THREAD_MEMBERS]: 'threads.max_thread_members_reached', + [APIErrorCodes.MAX_PINNED_THREADS_IN_FORUM]: 'threads.max_pinned_threads_reached', + [APIErrorCodes.MAX_FORUM_TAGS]: 'threads.max_forum_tags_reached', + [APIErrorCodes.FORUM_TAG_NAMES_MUST_BE_UNIQUE]: 'threads.forum_tag_names_must_be_unique', + [APIErrorCodes.NO_TAGS_AVAILABLE_TO_NON_MODERATORS]: 'threads.no_tags_available_to_non_moderators', + [APIErrorCodes.FORUM_TAG_REQUIRED]: 'threads.forum_tag_required', + [APIErrorCodes.UNKNOWN_FORUM_TAG]: 'unknown_entities.unknown_forum_tag', + [APIErrorCodes.INVALID_THREAD_NOTIFICATION_SETTINGS]: 'threads.invalid_thread_notification_settings', + [APIErrorCodes.HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY]: 'threads.hide_media_download_option_media_only', + [APIErrorCodes.WEBHOOK_FORUM_TARGET_REQUIRED]: 'webhooks.forum_target_required', + [APIErrorCodes.WEBHOOK_FORUM_TARGET_CONFLICT]: 'webhooks.forum_target_conflict', + [APIErrorCodes.WEBHOOK_THREAD_NAME_REQUIRES_FORUM]: 'webhooks.thread_name_requires_forum', + [APIErrorCodes.WEBHOOK_SERVICE_FORUM_UNSUPPORTED]: 'webhooks.service_forum_unsupported', + [APIErrorCodes.SEARCH_INDEX_NOT_READY]: 'threads.search_index_not_ready', + [APIErrorCodes.UNKNOWN_THREAD_MEMBER]: 'unknown_entities.unknown_thread_member', [APIErrorCodes.UPDATE_FAILED]: 'admin_and_system.update_failed', [APIErrorCodes.USERNAME_NOT_AVAILABLE]: 'usernames_and_tags.username_not_available', [APIErrorCodes.USERNAME_SIGN_IN_ONLY]: 'auth_and_registration.username_sign_in_only', diff --git a/packages/errors/src/i18n/ErrorI18nMessages.ts b/packages/errors/src/i18n/ErrorI18nMessages.ts index 012f66472..c10a27775 100644 --- a/packages/errors/src/i18n/ErrorI18nMessages.ts +++ b/packages/errors/src/i18n/ErrorI18nMessages.ts @@ -532,18 +532,35 @@ export const ERROR_I18N_MESSAGES = { 'stripe.webhook_not_available': "Webhook processing isn't available.", 'stripe.webhook_signature_invalid': 'Stripe webhook signature is invalid.', 'stripe.webhook_signature_missing': 'Stripe webhook signature is missing.', + 'threads.channel_has_threads': "Channels with threads can't be converted to announcement channels.", + 'threads.forum_tag_names_must_be_unique': 'Tag names must be unique.', + 'threads.forum_tag_required': 'A tag is required to create a post in this channel.', + 'threads.hide_media_download_option_media_only': 'Hiding media download options is only available in media channels.', + 'threads.invalid_thread_notification_settings': 'Invalid thread notification settings.', + 'threads.max_active_threads_reached': 'This community has reached its active thread limit of {count}.', + 'threads.max_forum_tags_reached': 'This channel has reached its tag limit of {count}.', + 'threads.max_pinned_threads_reached': 'This channel has reached its pinned post limit of {count}.', + 'threads.max_thread_members_reached': 'This thread has reached its member limit of {count}.', + 'threads.no_tags_available_to_non_moderators': + 'A tag is required, but only moderators can apply the tags in this channel.', + 'threads.search_index_not_ready': "The search index isn't ready yet. Try again in a moment.", + 'threads.thread_already_created_for_message': 'A thread has already been created for this message.', + 'threads.thread_archived': 'This thread is archived.', + 'threads.thread_locked': 'This thread is locked.', 'tickets.ticket_already_completed': 'This ticket has already been completed.', 'tos_and_age.date_of_birth_format_invalid': 'Invalid date of birth format.', 'tos_and_age.must_agree_to_tos_and_privacy': 'You must agree to the Terms of service and Privacy policy.', 'tos_and_age.must_be_minimum_age': 'You must be at least {minAge, plural, one {# year} other {# years}} old to create an account.', 'unknown_entities.unknown_channel': "Channel wasn't found.", + 'unknown_entities.unknown_forum_tag': "Tag wasn't found.", 'unknown_entities.unknown_guild': "Community wasn't found.", 'unknown_entities.unknown_invite': "Invite wasn't found or is no longer valid.", 'unknown_entities.unknown_member': "Member wasn't found in this community.", 'unknown_entities.unknown_message': "Message wasn't found.", 'unknown_entities.unknown_role': "Role wasn't found.", 'unknown_entities.unknown_user': "User wasn't found.", + 'unknown_entities.unknown_thread_member': "Thread member wasn't found.", 'unknown_entities.unknown_user_flag': "The specified user flag isn't recognized.", 'urls.invalid_url_format': 'Invalid URL format.', 'urls.invalid_url_or_attachment_reference': 'Invalid URL format or attachment reference.', @@ -580,10 +597,15 @@ export const ERROR_I18N_MESSAGES = { 'vanity_urls.vanity_url_invalid_characters': 'Vanity URL can only contain lowercase letters (a-z), digits (0-9), and hyphens (-).', 'webhooks.cannot_modify_system_webhook': "You can't edit system messages.", + 'webhooks.forum_target_conflict': "You can't specify both `thread_id` and `thread_name`.", + 'webhooks.forum_target_required': 'Webhooks posting to this channel need a `thread_id` or a `thread_name`.', 'webhooks.max_webhooks_per_channel_reached': "You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per channel.", 'webhooks.max_webhooks_per_guild_reached': "You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per community.", + 'webhooks.service_forum_unsupported': + "This webhook service can't post to a forum or media channel without a `thread_id`.", + 'webhooks.thread_name_requires_forum': '`thread_name` and `applied_tags` only work in forum and media channels.', 'webhooks.unknown_webhook': 'Unknown webhook.', 'webhooks.webhook_name_length_invalid': 'Webhook name must be between {min} and {max} characters.', 'entrance_sounds.invalid_format': 'Entrance sounds must be MP3, OGG, M4A, or WAV audio.', diff --git a/packages/errors/src/i18n/locales/ar.ts b/packages/errors/src/i18n/locales/ar.ts index b7294e97b..73c550a4f 100644 --- a/packages/errors/src/i18n/locales/ar.ts +++ b/packages/errors/src/i18n/locales/ar.ts @@ -449,16 +449,32 @@ const ERROR_I18N_AR_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "معالجة الويب هوك غير متاحة.", "stripe.webhook_signature_invalid": "توقيع ويب هوك Stripe غير صالح.", "stripe.webhook_signature_missing": "توقيع ويب هوك Stripe مفقود.", + "threads.channel_has_threads": "لا يمكن تحويل القنوات التي تحتوي على سلاسل رسائل إلى قنوات إعلانات.", + "threads.forum_tag_names_must_be_unique": "يجب أن تكون أسماء الوسوم فريدة.", + "threads.forum_tag_required": "يلزم وسم لإنشاء منشور في هذه القناة.", + "threads.hide_media_download_option_media_only": "إخفاء خيارات تنزيل الوسائط متاح في قنوات الوسائط فقط.", + "threads.invalid_thread_notification_settings": "إعدادات إشعارات سلسلة الرسائل غير صالحة.", + "threads.max_active_threads_reached": "وصل هذا المجتمع إلى الحد الأقصى لسلاسل الرسائل النشطة: {count}.", + "threads.max_forum_tags_reached": "وصلت هذه القناة إلى الحد الأقصى للوسوم: {count}.", + "threads.max_pinned_threads_reached": "وصلت هذه القناة إلى الحد الأقصى للمنشورات المثبتة: {count}.", + "threads.max_thread_members_reached": "وصلت سلسلة الرسائل هذه إلى الحد الأقصى للأعضاء: {count}.", + "threads.no_tags_available_to_non_moderators": "يلزم وسم، لكن المشرفين وحدهم يمكنهم إضافة الوسوم في هذه القناة.", + "threads.search_index_not_ready": "فهرس البحث غير جاهز بعد. حاول مرة أخرى بعد قليل.", + "threads.thread_already_created_for_message": "تم إنشاء سلسلة رسائل لهذه الرسالة بالفعل.", + "threads.thread_archived": "سلسلة الرسائل هذه مؤرشفة.", + "threads.thread_locked": "سلسلة الرسائل هذه مقفلة.", "tickets.ticket_already_completed": "تم إكمال هذه التذكرة بالفعل.", "tos_and_age.date_of_birth_format_invalid": "تنسيق تاريخ الميلاد غير صالح.", "tos_and_age.must_agree_to_tos_and_privacy": "يجب أن توافق على شروط الخدمة وسياسة الخصوصية.", "tos_and_age.must_be_minimum_age": "يجب أن يكون عمرك {minAge, plural, zero {# سنة} one {سنة واحدة} two {سنتين} few {# سنوات} many {# سنة} other {# سنة}} على الأقل لإنشاء حساب.", "unknown_entities.unknown_channel": "لم يتم العثور على القناة.", + "unknown_entities.unknown_forum_tag": "لم يتم العثور على الوسم.", "unknown_entities.unknown_guild": "لم يتم العثور على المجتمع.", "unknown_entities.unknown_invite": "لم يتم العثور على الدعوة أو لم تعد صالحة.", "unknown_entities.unknown_member": "لم يتم العثور على العضو في هذا المجتمع.", "unknown_entities.unknown_message": "لم يتم العثور على الرسالة.", "unknown_entities.unknown_role": "لم يتم العثور على الدور.", + "unknown_entities.unknown_thread_member": "لم يتم العثور على عضو سلسلة الرسائل.", "unknown_entities.unknown_user": "لم يتم العثور على المستخدم.", "unknown_entities.unknown_user_flag": "علامة المستخدم المحددة غير معروفة.", "urls.invalid_url_format": "صيغة الرابط غير صالحة.", @@ -491,8 +507,12 @@ const ERROR_I18N_AR_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "يجب أن يكون رمز عنوان URL المخصص بين {min} و{max} حرفًا.", "vanity_urls.vanity_url_invalid_characters": "يمكن أن يحتوي عنوان URL المخصص على أحرف صغيرة (a-z) وأرقام (0-9) وواصلات (-) فقط.", "webhooks.cannot_modify_system_webhook": "لا يمكنك تعديل رسائل النظام.", + "webhooks.forum_target_conflict": "لا يمكنك تحديد `thread_id` و`thread_name` معًا.", + "webhooks.forum_target_required": "تحتاج خطافات الويب التي تنشر في هذه القناة إلى `thread_id` أو `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "لقد وصلت إلى الحد الأقصى وهو {count, plural, zero {# ويب هوك} one {ويب هوك واحد} two {# ويب هوك} few {# ويب هوك} many {# ويب هوك} other {# ويب هوك}} لكل قناة.", "webhooks.max_webhooks_per_guild_reached": "لقد وصلت إلى الحد الأقصى وهو {count, plural, zero {# ويب هوك} one {ويب هوك واحد} two {# ويب هوك} few {# ويب هوك} many {# ويب هوك} other {# ويب هوك}} لكل مجتمع.", + "webhooks.service_forum_unsupported": "لا يمكن لخدمة خطاف الويب هذه النشر في قناة منتدى أو قناة وسائط دون `thread_id`.", + "webhooks.thread_name_requires_forum": "لا يعمل `thread_name` و`applied_tags` إلا في قنوات المنتدى والوسائط.", "webhooks.unknown_webhook": "ويب هوك غير معروف.", "webhooks.webhook_name_length_invalid": "يجب أن يكون اسم الويب هوك بين {min} و{max} حرفًا." }); diff --git a/packages/errors/src/i18n/locales/bg.ts b/packages/errors/src/i18n/locales/bg.ts index 5014464b8..2291c6466 100644 --- a/packages/errors/src/i18n/locales/bg.ts +++ b/packages/errors/src/i18n/locales/bg.ts @@ -449,16 +449,32 @@ const ERROR_I18N_BG_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Обработката на уебхукове не е достъпна.", "stripe.webhook_signature_invalid": "Подписът на уебхука от Stripe е невалиден.", "stripe.webhook_signature_missing": "Липсва подпис на уебхука от Stripe.", + "threads.channel_has_threads": "Канали с нишки не могат да се превръщат в канали за обяви.", + "threads.forum_tag_names_must_be_unique": "Имената на таговете трябва да са уникални.", + "threads.forum_tag_required": "За да създадеш публикация в този канал, е нужен таг.", + "threads.hide_media_download_option_media_only": "Скриването на опциите за изтегляне на медия е достъпно само в медийни канали.", + "threads.invalid_thread_notification_settings": "Невалидни настройки за известия на нишката.", + "threads.max_active_threads_reached": "Тази общност достигна лимита си за активни нишки: {count}.", + "threads.max_forum_tags_reached": "Този канал достигна лимита си за тагове: {count}.", + "threads.max_pinned_threads_reached": "Този канал достигна лимита си за закачени публикации: {count}.", + "threads.max_thread_members_reached": "Тази нишка достигна лимита си за членове: {count}.", + "threads.no_tags_available_to_non_moderators": "Нужен е таг, но в този канал само модераторите могат да добавят тагове.", + "threads.search_index_not_ready": "Индексът за търсене още не е готов. Опитай отново след малко.", + "threads.thread_already_created_for_message": "За това съобщение вече е създадена нишка.", + "threads.thread_archived": "Тази нишка е архивирана.", + "threads.thread_locked": "Тази нишка е заключена.", "tickets.ticket_already_completed": "Този тикет вече е приключен.", "tos_and_age.date_of_birth_format_invalid": "Невалиден формат на дата на раждане.", "tos_and_age.must_agree_to_tos_and_privacy": "Трябва да се съгласиш с Общите условия и Политиката за поверителност.", "tos_and_age.must_be_minimum_age": "Трябва да си поне на {minAge} {minAge, plural, one {година} other {години}}, за да създадеш акаунт.", "unknown_entities.unknown_channel": "Каналът не е намерен.", + "unknown_entities.unknown_forum_tag": "Тагът не е намерен.", "unknown_entities.unknown_guild": "Общността не е намерена.", "unknown_entities.unknown_invite": "Поканата не е намерена или вече не е валидна.", "unknown_entities.unknown_member": "Членът не е намерен в тази общност.", "unknown_entities.unknown_message": "Съобщението не е намерено.", "unknown_entities.unknown_role": "Ролята не е намерена.", + "unknown_entities.unknown_thread_member": "Членът на нишката не е намерен.", "unknown_entities.unknown_user": "Потребителят не е намерен.", "unknown_entities.unknown_user_flag": "Посоченият потребителски флаг не е разпознат.", "urls.invalid_url_format": "Невалиден URL формат.", @@ -491,8 +507,12 @@ const ERROR_I18N_BG_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Кодът за персонализиран URL адрес трябва да е между {min} и {max} знака.", "vanity_urls.vanity_url_invalid_characters": "Персонализираният URL адрес може да съдържа само малки букви (a-z), цифри (0-9) и тирета (-).", "webhooks.cannot_modify_system_webhook": "Не можеш да редактираш системни съобщения.", + "webhooks.forum_target_conflict": "Не можеш да посочиш едновременно `thread_id` и `thread_name`.", + "webhooks.forum_target_required": "Уебхуковете, които публикуват в този канал, трябва да имат `thread_id` или `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Достигна максимума от {count} {count, plural, one {уебхук} other {уебхука}} за канал.", "webhooks.max_webhooks_per_guild_reached": "Достигна максимума от {count} {count, plural, one {уебхук} other {уебхука}} за общност.", + "webhooks.service_forum_unsupported": "Тази уебхук услуга не може да публикува във форумен или медиен канал без `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` и `applied_tags` работят само във форумни и медийни канали.", "webhooks.unknown_webhook": "Неизвестен уебхук.", "webhooks.webhook_name_length_invalid": "Името на уебхука трябва да е между {min} и {max} знака." }); diff --git a/packages/errors/src/i18n/locales/cs.ts b/packages/errors/src/i18n/locales/cs.ts index 9efbf1d16..13acd3169 100644 --- a/packages/errors/src/i18n/locales/cs.ts +++ b/packages/errors/src/i18n/locales/cs.ts @@ -449,16 +449,32 @@ const ERROR_I18N_CS_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Zpracování webhooků není dostupné.", "stripe.webhook_signature_invalid": "Podpis Stripe webhooku je neplatný.", "stripe.webhook_signature_missing": "Chybí podpis Stripe webhooku.", + "threads.channel_has_threads": "Kanály s vlákny nelze převést na oznamovací kanály.", + "threads.forum_tag_names_must_be_unique": "Názvy štítků musí být jedinečné.", + "threads.forum_tag_required": "K vytvoření příspěvku v tomto kanálu je potřeba štítek.", + "threads.hide_media_download_option_media_only": "Skrytí možností stahování médií je dostupné jen v kanálech médií.", + "threads.invalid_thread_notification_settings": "Neplatné nastavení oznámení vlákna.", + "threads.max_active_threads_reached": "Tato komunita dosáhla limitu aktivních vláken: {count}.", + "threads.max_forum_tags_reached": "Tento kanál dosáhl limitu štítků: {count}.", + "threads.max_pinned_threads_reached": "Tento kanál dosáhl limitu připnutých příspěvků: {count}.", + "threads.max_thread_members_reached": "Toto vlákno dosáhlo limitu členů: {count}.", + "threads.no_tags_available_to_non_moderators": "Štítek je povinný, ale v tomto kanálu mohou štítky přidávat jen moderátoři.", + "threads.search_index_not_ready": "Vyhledávací index ještě není připravený. Zkus to za chvíli znovu.", + "threads.thread_already_created_for_message": "Pro tuto zprávu už bylo vlákno vytvořeno.", + "threads.thread_archived": "Toto vlákno je archivované.", + "threads.thread_locked": "Toto vlákno je uzamčené.", "tickets.ticket_already_completed": "Tento tiket už byl dokončen.", "tos_and_age.date_of_birth_format_invalid": "Neplatný formát data narození.", "tos_and_age.must_agree_to_tos_and_privacy": "Musíte souhlasit s podmínkami služby a zásadami ochrany osobních údajů.", "tos_and_age.must_be_minimum_age": "K vytvoření účtu vám musí být alespoň {minAge, plural, one {# rok} few {# roky} many {# roku} other {# let}}.", "unknown_entities.unknown_channel": "Kanál nebyl nalezen.", + "unknown_entities.unknown_forum_tag": "Štítek nebyl nalezen.", "unknown_entities.unknown_guild": "Komunita nebyla nalezena.", "unknown_entities.unknown_invite": "Pozvánka nebyla nalezena nebo už není platná.", "unknown_entities.unknown_member": "Člen nebyl v této komunitě nalezen.", "unknown_entities.unknown_message": "Zpráva nebyla nalezena.", "unknown_entities.unknown_role": "Role nebyla nalezena.", + "unknown_entities.unknown_thread_member": "Člen vlákna nebyl nalezen.", "unknown_entities.unknown_user": "Uživatel nebyl nalezen.", "unknown_entities.unknown_user_flag": "Zadaný uživatelský příznak není rozpoznán.", "urls.invalid_url_format": "Neplatný formát adresy URL.", @@ -491,8 +507,12 @@ const ERROR_I18N_CS_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Kód vlastního odkazu na pozvánku musí mít {min} až {max} znaků.", "vanity_urls.vanity_url_invalid_characters": "Kód vlastního odkazu na pozvánku smí obsahovat pouze malá písmena (a-z), číslice (0-9) a spojovníky (-).", "webhooks.cannot_modify_system_webhook": "Systémové zprávy nelze upravovat.", + "webhooks.forum_target_conflict": "Nemůžeš zadat zároveň `thread_id` i `thread_name`.", + "webhooks.forum_target_required": "Webhooky, které publikují do tohoto kanálu, potřebují `thread_id` nebo `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Dosáhli jste limitu {count, plural, one {# webhooku} few {# webhooků} many {# webhooku} other {# webhooků}} na kanál.", "webhooks.max_webhooks_per_guild_reached": "Dosáhli jste limitu {count, plural, one {# webhooku} few {# webhooků} many {# webhooku} other {# webhooků}} na komunitu.", + "webhooks.service_forum_unsupported": "Tato služba webhooků nemůže bez `thread_id` publikovat do kanálu fóra ani médií.", + "webhooks.thread_name_requires_forum": "`thread_name` a `applied_tags` fungují jen v kanálech fóra a médií.", "webhooks.unknown_webhook": "Neznámý webhook.", "webhooks.webhook_name_length_invalid": "Název webhooku musí být dlouhý {min} až {max} znaků." }); diff --git a/packages/errors/src/i18n/locales/da.ts b/packages/errors/src/i18n/locales/da.ts index 143994528..5d5e4b5ea 100644 --- a/packages/errors/src/i18n/locales/da.ts +++ b/packages/errors/src/i18n/locales/da.ts @@ -449,16 +449,32 @@ const ERROR_I18N_DA_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Webhook-behandlingen er ikke tilgængelig.", "stripe.webhook_signature_invalid": "Stripe-webhooksignaturen er ugyldig.", "stripe.webhook_signature_missing": "Stripe-webhooksignaturen mangler.", + "threads.channel_has_threads": "Kanaler med tråde kan ikke konverteres til meddelelseskanaler.", + "threads.forum_tag_names_must_be_unique": "Tagnavne skal være unikke.", + "threads.forum_tag_required": "Der kræves et tag for at oprette et opslag i denne kanal.", + "threads.hide_media_download_option_media_only": "Det er kun i mediekanaler, at downloadmuligheder for medier kan skjules.", + "threads.invalid_thread_notification_settings": "Ugyldige notifikationsindstillinger for tråden.", + "threads.max_active_threads_reached": "Dette fællesskab har nået sin grænse for aktive tråde: {count}.", + "threads.max_forum_tags_reached": "Denne kanal har nået sin grænse for tags: {count}.", + "threads.max_pinned_threads_reached": "Denne kanal har nået sin grænse for fastgjorte opslag: {count}.", + "threads.max_thread_members_reached": "Denne tråd har nået sin grænse for medlemmer: {count}.", + "threads.no_tags_available_to_non_moderators": "Der kræves et tag, men i denne kanal er det kun moderatorer, der kan tilføje tags.", + "threads.search_index_not_ready": "Søgeindekset er ikke klar endnu. Prøv igen om et øjeblik.", + "threads.thread_already_created_for_message": "Der er allerede oprettet en tråd til denne besked.", + "threads.thread_archived": "Denne tråd er arkiveret.", + "threads.thread_locked": "Denne tråd er låst.", "tickets.ticket_already_completed": "Denne sag er allerede afsluttet.", "tos_and_age.date_of_birth_format_invalid": "Ugyldigt fødselsdatoformat.", "tos_and_age.must_agree_to_tos_and_privacy": "Du skal acceptere servicevilkårene og fortrolighedspolitikken.", "tos_and_age.must_be_minimum_age": "Du skal være mindst {minAge} {minAge, plural, one {år} other {år}} gammel for at oprette en konto.", "unknown_entities.unknown_channel": "Kanalen blev ikke fundet.", + "unknown_entities.unknown_forum_tag": "Tagget blev ikke fundet.", "unknown_entities.unknown_guild": "Fællesskabet blev ikke fundet.", "unknown_entities.unknown_invite": "Invitationen blev ikke fundet eller er ikke længere gyldig.", "unknown_entities.unknown_member": "Medlemmet blev ikke fundet i dette fællesskab.", "unknown_entities.unknown_message": "Beskeden blev ikke fundet.", "unknown_entities.unknown_role": "Rollen blev ikke fundet.", + "unknown_entities.unknown_thread_member": "Trådmedlemmet blev ikke fundet.", "unknown_entities.unknown_user": "Brugeren blev ikke fundet.", "unknown_entities.unknown_user_flag": "Det angivne brugerflag genkendes ikke.", "urls.invalid_url_format": "Ugyldigt URL-format.", @@ -491,8 +507,12 @@ const ERROR_I18N_DA_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Koden til det brugerdefinerede invitationslink skal være på mellem {min} og {max} tegn.", "vanity_urls.vanity_url_invalid_characters": "Det brugerdefinerede invitationslink må kun indeholde små bogstaver (a-z), tal (0-9) og bindestreger (-).", "webhooks.cannot_modify_system_webhook": "Systembeskeder kan ikke redigeres.", + "webhooks.forum_target_conflict": "Du kan ikke angive både `thread_id` og `thread_name`.", + "webhooks.forum_target_required": "Webhooks, der poster i denne kanal, skal have et `thread_id` eller et `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Du har nået maksimum på {count} {count, plural, one {webhook} other {webhooks}} pr. kanal.", "webhooks.max_webhooks_per_guild_reached": "Du har nået grænsen på {count, plural, one {# webhook} other {# webhooks}} pr. fællesskab.", + "webhooks.service_forum_unsupported": "Denne webhooktjeneste kan ikke poste i en forum- eller mediekanal uden et `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` og `applied_tags` virker kun i forum- og mediekanaler.", "webhooks.unknown_webhook": "Ukendt webhook.", "webhooks.webhook_name_length_invalid": "Webhooknavnet skal være på mellem {min} og {max} tegn." }); diff --git a/packages/errors/src/i18n/locales/de.ts b/packages/errors/src/i18n/locales/de.ts index 7fe74b260..c672b1266 100644 --- a/packages/errors/src/i18n/locales/de.ts +++ b/packages/errors/src/i18n/locales/de.ts @@ -449,16 +449,32 @@ const ERROR_I18N_DE_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Webhook-Verarbeitung ist nicht verfügbar.", "stripe.webhook_signature_invalid": "Die Stripe-Webhook-Signatur ist ungültig.", "stripe.webhook_signature_missing": "Die Stripe-Webhook-Signatur fehlt.", + "threads.channel_has_threads": "Kanäle mit Threads können nicht in Ankündigungskanäle umgewandelt werden.", + "threads.forum_tag_names_must_be_unique": "Tag-Namen müssen eindeutig sein.", + "threads.forum_tag_required": "Für einen Beitrag in diesem Kanal ist ein Tag erforderlich.", + "threads.hide_media_download_option_media_only": "Downloadoptionen für Medien lassen sich nur in Medienkanälen ausblenden.", + "threads.invalid_thread_notification_settings": "Ungültige Benachrichtigungseinstellungen für den Thread.", + "threads.max_active_threads_reached": "Diese Community hat ihr Limit für aktive Threads erreicht: {count}.", + "threads.max_forum_tags_reached": "Dieser Kanal hat sein Tag-Limit erreicht: {count}.", + "threads.max_pinned_threads_reached": "Dieser Kanal hat sein Limit für angepinnte Beiträge erreicht: {count}.", + "threads.max_thread_members_reached": "Dieser Thread hat sein Mitgliederlimit erreicht: {count}.", + "threads.no_tags_available_to_non_moderators": "Ein Tag ist erforderlich, aber in diesem Kanal können nur Moderatoren Tags vergeben.", + "threads.search_index_not_ready": "Der Suchindex ist noch nicht bereit. Versuch es gleich noch einmal.", + "threads.thread_already_created_for_message": "Für diese Nachricht wurde bereits ein Thread erstellt.", + "threads.thread_archived": "Dieser Thread ist archiviert.", + "threads.thread_locked": "Dieser Thread ist gesperrt.", "tickets.ticket_already_completed": "Dieses Ticket wurde bereits abgeschlossen.", "tos_and_age.date_of_birth_format_invalid": "Ungültiges Format des Geburtsdatums.", "tos_and_age.must_agree_to_tos_and_privacy": "Du musst den Nutzungsbedingungen und der Datenschutzerklärung zustimmen.", "tos_and_age.must_be_minimum_age": "Du musst mindestens {minAge, plural, one {# Jahr} other {# Jahre}} alt sein, um einen Account zu erstellen.", "unknown_entities.unknown_channel": "Kanal nicht gefunden.", + "unknown_entities.unknown_forum_tag": "Tag nicht gefunden.", "unknown_entities.unknown_guild": "Community nicht gefunden.", "unknown_entities.unknown_invite": "Einladung nicht gefunden oder nicht mehr gültig.", "unknown_entities.unknown_member": "Mitglied in dieser Community nicht gefunden.", "unknown_entities.unknown_message": "Nachricht nicht gefunden.", "unknown_entities.unknown_role": "Rolle nicht gefunden.", + "unknown_entities.unknown_thread_member": "Thread-Mitglied nicht gefunden.", "unknown_entities.unknown_user": "Benutzer nicht gefunden.", "unknown_entities.unknown_user_flag": "Das angegebene Benutzer-Flag wird nicht erkannt.", "urls.invalid_url_format": "Ungültiges Format der URL.", @@ -491,8 +507,12 @@ const ERROR_I18N_DE_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Der Vanity-URL-Code muss zwischen {min} und {max} Zeichen lang sein.", "vanity_urls.vanity_url_invalid_characters": "Die Vanity-URL darf nur Kleinbuchstaben (a-z), Ziffern (0-9) und Bindestriche (-) enthalten.", "webhooks.cannot_modify_system_webhook": "Du kannst keine Systemnachrichten bearbeiten.", + "webhooks.forum_target_conflict": "Du kannst nicht gleichzeitig `thread_id` und `thread_name` angeben.", + "webhooks.forum_target_required": "Webhooks, die in diesem Kanal posten, brauchen eine `thread_id` oder einen `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Du hast das Maximum von {count, plural, one {# Webhook} other {# Webhooks}} pro Kanal erreicht.", "webhooks.max_webhooks_per_guild_reached": "Du hast das Maximum von {count, plural, one {# Webhook} other {# Webhooks}} pro Community erreicht.", + "webhooks.service_forum_unsupported": "Dieser Webhook-Dienst kann ohne `thread_id` nicht in einem Forum- oder Medienkanal posten.", + "webhooks.thread_name_requires_forum": "`thread_name` und `applied_tags` funktionieren nur in Forum- und Medienkanälen.", "webhooks.unknown_webhook": "Unbekannter Webhook.", "webhooks.webhook_name_length_invalid": "Der Webhook-Name muss zwischen {min} und {max} Zeichen lang sein." }); diff --git a/packages/errors/src/i18n/locales/el.ts b/packages/errors/src/i18n/locales/el.ts index 72aa5e778..15a6c5360 100644 --- a/packages/errors/src/i18n/locales/el.ts +++ b/packages/errors/src/i18n/locales/el.ts @@ -449,16 +449,32 @@ const ERROR_I18N_EL_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Η επεξεργασία webhook δεν είναι διαθέσιμη.", "stripe.webhook_signature_invalid": "Η υπογραφή webhook του Stripe είναι άκυρη.", "stripe.webhook_signature_missing": "Η υπογραφή webhook του Stripe λείπει.", + "threads.channel_has_threads": "Τα κανάλια με νήματα δεν μπορούν να μετατραπούν σε κανάλια ανακοινώσεων.", + "threads.forum_tag_names_must_be_unique": "Τα ονόματα των ετικετών πρέπει να είναι μοναδικά.", + "threads.forum_tag_required": "Για να δημιουργήσεις ανάρτηση σε αυτό το κανάλι χρειάζεται ετικέτα.", + "threads.hide_media_download_option_media_only": "Η απόκρυψη των επιλογών λήψης πολυμέσων είναι διαθέσιμη μόνο σε κανάλια πολυμέσων.", + "threads.invalid_thread_notification_settings": "Μη έγκυρες ρυθμίσεις ειδοποιήσεων νήματος.", + "threads.max_active_threads_reached": "Αυτή η κοινότητα έφτασε το όριο ενεργών νημάτων: {count}.", + "threads.max_forum_tags_reached": "Αυτό το κανάλι έφτασε το όριο ετικετών: {count}.", + "threads.max_pinned_threads_reached": "Αυτό το κανάλι έφτασε το όριο καρφιτσωμένων αναρτήσεων: {count}.", + "threads.max_thread_members_reached": "Αυτό το νήμα έφτασε το όριο μελών: {count}.", + "threads.no_tags_available_to_non_moderators": "Χρειάζεται ετικέτα, αλλά σε αυτό το κανάλι μόνο οι συντονιστές μπορούν να προσθέτουν ετικέτες.", + "threads.search_index_not_ready": "Το ευρετήριο αναζήτησης δεν είναι ακόμα έτοιμο. Δοκίμασε ξανά σε λίγο.", + "threads.thread_already_created_for_message": "Έχει ήδη δημιουργηθεί νήμα για αυτό το μήνυμα.", + "threads.thread_archived": "Αυτό το νήμα είναι αρχειοθετημένο.", + "threads.thread_locked": "Αυτό το νήμα είναι κλειδωμένο.", "tickets.ticket_already_completed": "Αυτό το ticket έχει ήδη ολοκληρωθεί.", "tos_and_age.date_of_birth_format_invalid": "Μη έγκυρη μορφή ημερομηνίας γέννησης.", "tos_and_age.must_agree_to_tos_and_privacy": "Πρέπει να συμφωνήσεις με τους όρους παροχής υπηρεσιών και την πολιτική απορρήτου.", "tos_and_age.must_be_minimum_age": "Πρέπει να είσαι τουλάχιστον {minAge} {minAge, plural, one {έτους} other {ετών}} για να δημιουργήσεις λογαριασμό.", "unknown_entities.unknown_channel": "Δεν βρήκαμε το κανάλι.", + "unknown_entities.unknown_forum_tag": "Δεν βρέθηκε η ετικέτα.", "unknown_entities.unknown_guild": "Δεν βρήκαμε την κοινότητα.", "unknown_entities.unknown_invite": "Δεν βρήκαμε την πρόσκληση ή δεν ισχύει πλέον.", "unknown_entities.unknown_member": "Δεν βρήκαμε το μέλος σε αυτήν την κοινότητα.", "unknown_entities.unknown_message": "Δεν βρήκαμε το μήνυμα.", "unknown_entities.unknown_role": "Δεν βρήκαμε τον ρόλο.", + "unknown_entities.unknown_thread_member": "Δεν βρέθηκε το μέλος του νήματος.", "unknown_entities.unknown_user": "Δεν βρήκαμε τον χρήστη.", "unknown_entities.unknown_user_flag": "Η σημαία χρήστη που καθορίστηκε δεν αναγνωρίζεται.", "urls.invalid_url_format": "Μη έγκυρη μορφή URL.", @@ -491,8 +507,12 @@ const ERROR_I18N_EL_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Ο κωδικός vanity URL πρέπει να είναι μεταξύ {min} και {max} χαρακτήρων.", "vanity_urls.vanity_url_invalid_characters": "Το vanity URL μπορεί να περιέχει μόνο πεζά γράμματα (a-z), ψηφία (0-9) και παύλες (-).", "webhooks.cannot_modify_system_webhook": "Δεν μπορείς να επεξεργαστείς μηνύματα συστήματος.", + "webhooks.forum_target_conflict": "Δεν μπορείς να ορίσεις ταυτόχρονα `thread_id` και `thread_name`.", + "webhooks.forum_target_required": "Τα webhook που δημοσιεύουν σε αυτό το κανάλι χρειάζονται `thread_id` ή `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Έχεις φτάσει το μέγιστο των {count} {count, plural, one {webhook} other {webhook}} ανά κανάλι.", "webhooks.max_webhooks_per_guild_reached": "Έχεις φτάσει το μέγιστο των {count} {count, plural, one {webhook} other {webhook}} ανά κοινότητα.", + "webhooks.service_forum_unsupported": "Αυτή η υπηρεσία webhook δεν μπορεί να δημοσιεύσει σε κανάλι φόρουμ ή πολυμέσων χωρίς `thread_id`.", + "webhooks.thread_name_requires_forum": "Τα `thread_name` και `applied_tags` λειτουργούν μόνο σε κανάλια φόρουμ και πολυμέσων.", "webhooks.unknown_webhook": "Άγνωστο webhook.", "webhooks.webhook_name_length_invalid": "Το όνομα webhook πρέπει να είναι μεταξύ {min} και {max} χαρακτήρων." }); diff --git a/packages/errors/src/i18n/locales/en-GB.ts b/packages/errors/src/i18n/locales/en-GB.ts index 038b235b5..2b67e15e2 100644 --- a/packages/errors/src/i18n/locales/en-GB.ts +++ b/packages/errors/src/i18n/locales/en-GB.ts @@ -449,16 +449,32 @@ const ERROR_I18N_EN_GB_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Webhook processing isn't available.", "stripe.webhook_signature_invalid": "Stripe webhook signature is invalid.", "stripe.webhook_signature_missing": "Stripe webhook signature is missing.", + "threads.channel_has_threads": "Channels with threads can't be converted to announcement channels.", + "threads.forum_tag_names_must_be_unique": "Tag names must be unique.", + "threads.forum_tag_required": "A tag is required to create a post in this channel.", + "threads.hide_media_download_option_media_only": "Hiding media download options is only available in media channels.", + "threads.invalid_thread_notification_settings": "Invalid thread notification settings.", + "threads.max_active_threads_reached": "You've reached the maximum of {count, plural, one {# active thread} other {# active threads}} in this community.", + "threads.max_forum_tags_reached": "You've reached the maximum of {count, plural, one {# tag} other {# tags}} in this channel.", + "threads.max_pinned_threads_reached": "You've reached the maximum of {count, plural, one {# pinned post} other {# pinned posts}} in this channel.", + "threads.max_thread_members_reached": "This thread has reached the maximum of {count, plural, one {# member} other {# members}}.", + "threads.no_tags_available_to_non_moderators": "A tag is required, but only moderators can apply the tags in this channel.", + "threads.search_index_not_ready": "The search index isn't ready yet. Try again in a moment.", + "threads.thread_already_created_for_message": "A thread has already been created for this message.", + "threads.thread_archived": "This thread is archived.", + "threads.thread_locked": "This thread is locked.", "tickets.ticket_already_completed": "This ticket has already been completed.", "tos_and_age.date_of_birth_format_invalid": "Invalid date of birth format.", "tos_and_age.must_agree_to_tos_and_privacy": "You must agree to the Terms of service and Privacy policy.", "tos_and_age.must_be_minimum_age": "You must be at least {minAge, plural, one {# year} other {# years}} old to create an account.", "unknown_entities.unknown_channel": "Channel wasn't found.", + "unknown_entities.unknown_forum_tag": "Tag wasn't found.", "unknown_entities.unknown_guild": "Community wasn't found.", "unknown_entities.unknown_invite": "Invite wasn't found or is no longer valid.", "unknown_entities.unknown_member": "Member wasn't found in this community.", "unknown_entities.unknown_message": "Message wasn't found.", "unknown_entities.unknown_role": "Role wasn't found.", + "unknown_entities.unknown_thread_member": "Thread member wasn't found.", "unknown_entities.unknown_user": "User wasn't found.", "unknown_entities.unknown_user_flag": "The specified user flag isn't recognised.", "urls.invalid_url_format": "Invalid URL format.", @@ -491,8 +507,12 @@ const ERROR_I18N_EN_GB_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Vanity URL code must be between {min} and {max} characters.", "vanity_urls.vanity_url_invalid_characters": "Vanity URL can only contain lowercase letters (a-z), digits (0-9), and hyphens (-).", "webhooks.cannot_modify_system_webhook": "You can't edit system messages.", + "webhooks.forum_target_conflict": "You can't specify both `thread_id` and `thread_name`.", + "webhooks.forum_target_required": "Webhooks posting to this channel need a `thread_id` or a `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per channel.", "webhooks.max_webhooks_per_guild_reached": "You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per community.", + "webhooks.service_forum_unsupported": "This webhook service can't post to a forum or media channel without a `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` and `applied_tags` only work in forum and media channels.", "webhooks.unknown_webhook": "Unknown webhook.", "webhooks.webhook_name_length_invalid": "Webhook name must be between {min} and {max} characters." }); diff --git a/packages/errors/src/i18n/locales/es-419.ts b/packages/errors/src/i18n/locales/es-419.ts index 8d72251fc..007c45186 100644 --- a/packages/errors/src/i18n/locales/es-419.ts +++ b/packages/errors/src/i18n/locales/es-419.ts @@ -449,16 +449,32 @@ const ERROR_I18N_ES_419_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "El procesamiento de webhooks no está disponible.", "stripe.webhook_signature_invalid": "La firma del webhook de Stripe no es válida.", "stripe.webhook_signature_missing": "Falta la firma del webhook de Stripe.", + "threads.channel_has_threads": "Los canales con hilos no se pueden convertir en canales de anuncios.", + "threads.forum_tag_names_must_be_unique": "Los nombres de las etiquetas deben ser únicos.", + "threads.forum_tag_required": "Se necesita una etiqueta para crear una publicación en este canal.", + "threads.hide_media_download_option_media_only": "Ocultar las opciones de descarga de contenido multimedia solo está disponible en los canales multimedia.", + "threads.invalid_thread_notification_settings": "La configuración de notificaciones del hilo no es válida.", + "threads.max_active_threads_reached": "Esta comunidad alcanzó su límite de hilos activos: {count}.", + "threads.max_forum_tags_reached": "Este canal alcanzó su límite de etiquetas: {count}.", + "threads.max_pinned_threads_reached": "Este canal alcanzó su límite de publicaciones fijadas: {count}.", + "threads.max_thread_members_reached": "Este hilo alcanzó su límite de miembros: {count}.", + "threads.no_tags_available_to_non_moderators": "Se necesita una etiqueta, pero en este canal solo los moderadores pueden aplicar etiquetas.", + "threads.search_index_not_ready": "El índice de búsqueda todavía no está listo. Vuelve a intentarlo en un momento.", + "threads.thread_already_created_for_message": "Ya se creó un hilo para este mensaje.", + "threads.thread_archived": "Este hilo está archivado.", + "threads.thread_locked": "Este hilo está bloqueado.", "tickets.ticket_already_completed": "Este ticket ya fue completado.", "tos_and_age.date_of_birth_format_invalid": "Formato de fecha de nacimiento inválido.", "tos_and_age.must_agree_to_tos_and_privacy": "Debes aceptar los Términos del servicio y la Política de privacidad.", "tos_and_age.must_be_minimum_age": "Debes tener al menos {minAge} {minAge, plural, one {año} other {años}} para crear una cuenta.", "unknown_entities.unknown_channel": "No se encontró el canal.", + "unknown_entities.unknown_forum_tag": "No se encontró la etiqueta.", "unknown_entities.unknown_guild": "No se encontró la comunidad.", "unknown_entities.unknown_invite": "No se encontró la invitación o ya no es válida.", "unknown_entities.unknown_member": "No se encontró al miembro en esta comunidad.", "unknown_entities.unknown_message": "No se encontró el mensaje.", "unknown_entities.unknown_role": "No se encontró el rol.", + "unknown_entities.unknown_thread_member": "No se encontró al miembro del hilo.", "unknown_entities.unknown_user": "No se encontró al usuario.", "unknown_entities.unknown_user_flag": "El indicador de usuario especificado no se reconoce.", "urls.invalid_url_format": "Formato de URL inválido.", @@ -491,8 +507,12 @@ const ERROR_I18N_ES_419_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "El código de URL personalizada debe tener entre {min} y {max} caracteres.", "vanity_urls.vanity_url_invalid_characters": "La URL personalizada solo puede contener letras minúsculas (a-z), números (0-9) y guiones (-).", "webhooks.cannot_modify_system_webhook": "No puedes editar mensajes del sistema.", + "webhooks.forum_target_conflict": "No puedes indicar `thread_id` y `thread_name` al mismo tiempo.", + "webhooks.forum_target_required": "Los webhooks que publican en este canal necesitan un `thread_id` o un `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Has alcanzado el máximo de {count} {count, plural, one {webhook} other {webhooks}} por canal.", "webhooks.max_webhooks_per_guild_reached": "Has alcanzado el máximo de {count} {count, plural, one {webhook} other {webhooks}} por comunidad.", + "webhooks.service_forum_unsupported": "Este servicio de webhook no puede publicar en un canal de foro o multimedia sin un `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` y `applied_tags` solo funcionan en canales de foro y multimedia.", "webhooks.unknown_webhook": "Webhook desconocido.", "webhooks.webhook_name_length_invalid": "El nombre del webhook debe tener entre {min} y {max} caracteres." }); diff --git a/packages/errors/src/i18n/locales/es-ES.ts b/packages/errors/src/i18n/locales/es-ES.ts index f5349b100..3d5852ee8 100644 --- a/packages/errors/src/i18n/locales/es-ES.ts +++ b/packages/errors/src/i18n/locales/es-ES.ts @@ -449,16 +449,32 @@ const ERROR_I18N_ES_ES_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "El procesamiento de webhook no está disponible.", "stripe.webhook_signature_invalid": "La firma del webhook de Stripe no es válida.", "stripe.webhook_signature_missing": "Falta la firma del webhook de Stripe.", + "threads.channel_has_threads": "Los canales con hilos no se pueden convertir en canales de anuncios.", + "threads.forum_tag_names_must_be_unique": "Los nombres de las etiquetas deben ser únicos.", + "threads.forum_tag_required": "Se necesita una etiqueta para crear una publicación en este canal.", + "threads.hide_media_download_option_media_only": "Ocultar las opciones de descarga de contenido multimedia solo está disponible en los canales multimedia.", + "threads.invalid_thread_notification_settings": "La configuración de notificaciones del hilo no es válida.", + "threads.max_active_threads_reached": "Esta comunidad ha alcanzado su límite de hilos activos: {count}.", + "threads.max_forum_tags_reached": "Este canal ha alcanzado su límite de etiquetas: {count}.", + "threads.max_pinned_threads_reached": "Este canal ha alcanzado su límite de publicaciones fijadas: {count}.", + "threads.max_thread_members_reached": "Este hilo ha alcanzado su límite de miembros: {count}.", + "threads.no_tags_available_to_non_moderators": "Se necesita una etiqueta, pero en este canal solo los moderadores pueden aplicar etiquetas.", + "threads.search_index_not_ready": "El índice de búsqueda aún no está listo. Inténtalo de nuevo en un momento.", + "threads.thread_already_created_for_message": "Ya se ha creado un hilo para este mensaje.", + "threads.thread_archived": "Este hilo está archivado.", + "threads.thread_locked": "Este hilo está bloqueado.", "tickets.ticket_already_completed": "Este ticket ya se ha completado.", "tos_and_age.date_of_birth_format_invalid": "Formato de fecha de nacimiento no válido.", "tos_and_age.must_agree_to_tos_and_privacy": "Debes aceptar las Condiciones del servicio y la Política de privacidad.", "tos_and_age.must_be_minimum_age": "Debes tener al menos {minAge} {minAge, plural, one {año} other {años}} de edad para crear una cuenta.", "unknown_entities.unknown_channel": "No se encontró el canal.", + "unknown_entities.unknown_forum_tag": "No se encontró la etiqueta.", "unknown_entities.unknown_guild": "No se encontró la comunidad.", "unknown_entities.unknown_invite": "No se encontró la invitación o ya no es válida.", "unknown_entities.unknown_member": "No se encontró al miembro en esta comunidad.", "unknown_entities.unknown_message": "No se encontró el mensaje.", "unknown_entities.unknown_role": "No se encontró el rol.", + "unknown_entities.unknown_thread_member": "No se encontró al miembro del hilo.", "unknown_entities.unknown_user": "No se encontró el usuario.", "unknown_entities.unknown_user_flag": "El indicador de usuario especificado no se reconoce.", "urls.invalid_url_format": "Formato de URL no válido.", @@ -491,8 +507,12 @@ const ERROR_I18N_ES_ES_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "El código de la URL personalizada debe tener entre {min} y {max} caracteres.", "vanity_urls.vanity_url_invalid_characters": "La URL personalizada solo puede contener letras minúsculas (a-z), números (0-9) y guiones (-).", "webhooks.cannot_modify_system_webhook": "No puedes editar mensajes del sistema.", + "webhooks.forum_target_conflict": "No puedes indicar `thread_id` y `thread_name` a la vez.", + "webhooks.forum_target_required": "Los webhooks que publican en este canal necesitan un `thread_id` o un `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Has alcanzado el máximo de {count} {count, plural, one {webhook} other {webhooks}} por canal.", "webhooks.max_webhooks_per_guild_reached": "Has alcanzado el máximo de {count} {count, plural, one {webhook} other {webhooks}} por comunidad.", + "webhooks.service_forum_unsupported": "Este servicio de webhook no puede publicar en un canal de foro o multimedia sin un `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` y `applied_tags` solo funcionan en canales de foro y multimedia.", "webhooks.unknown_webhook": "Webhook desconocido.", "webhooks.webhook_name_length_invalid": "El nombre del webhook debe tener entre {min} y {max} caracteres." }); diff --git a/packages/errors/src/i18n/locales/fi.ts b/packages/errors/src/i18n/locales/fi.ts index a046cd710..6a84424c1 100644 --- a/packages/errors/src/i18n/locales/fi.ts +++ b/packages/errors/src/i18n/locales/fi.ts @@ -449,16 +449,32 @@ const ERROR_I18N_FI_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Webhook-käsittely ei ole käytettävissä.", "stripe.webhook_signature_invalid": "Stripe-webhookin allekirjoitus on virheellinen.", "stripe.webhook_signature_missing": "Stripe-webhookin allekirjoitus puuttuu.", + "threads.channel_has_threads": "Ketjuja sisältäviä kanavia ei voi muuttaa tiedotekanaviksi.", + "threads.forum_tag_names_must_be_unique": "Tunnisteiden nimien on oltava yksilöllisiä.", + "threads.forum_tag_required": "Julkaisun luominen tälle kanavalle vaatii tunnisteen.", + "threads.hide_media_download_option_media_only": "Median latausvalinnat voi piilottaa vain mediakanavilla.", + "threads.invalid_thread_notification_settings": "Virheelliset ketjun ilmoitusasetukset.", + "threads.max_active_threads_reached": "Tämä yhteisö on saavuttanut aktiivisten ketjujen rajan: {count}.", + "threads.max_forum_tags_reached": "Tämä kanava on saavuttanut tunnisteiden rajan: {count}.", + "threads.max_pinned_threads_reached": "Tämä kanava on saavuttanut kiinnitettyjen julkaisujen rajan: {count}.", + "threads.max_thread_members_reached": "Tämä ketju on saavuttanut jäsenten rajan: {count}.", + "threads.no_tags_available_to_non_moderators": "Tunniste vaaditaan, mutta tällä kanavalla vain moderaattorit voivat lisätä tunnisteita.", + "threads.search_index_not_ready": "Hakuhakemisto ei ole vielä valmis. Yritä hetken päästä uudelleen.", + "threads.thread_already_created_for_message": "Tälle viestille on jo luotu ketju.", + "threads.thread_archived": "Tämä ketju on arkistoitu.", + "threads.thread_locked": "Tämä ketju on lukittu.", "tickets.ticket_already_completed": "Tämä asia on jo käsitelty.", "tos_and_age.date_of_birth_format_invalid": "Virheellinen syntymäaikamuoto.", "tos_and_age.must_agree_to_tos_and_privacy": "Sinun on hyväksyttävä käyttöehdot ja tietosuojakäytäntö.", "tos_and_age.must_be_minimum_age": "Sinun on oltava vähintään {minAge, plural, one {# vuoden} other {# vuotta}} ikäinen luodaksesi tilin.", "unknown_entities.unknown_channel": "Kanavaa ei löytynyt.", + "unknown_entities.unknown_forum_tag": "Tunnistetta ei löytynyt.", "unknown_entities.unknown_guild": "Yhteisöä ei löytynyt.", "unknown_entities.unknown_invite": "Kutsua ei löytynyt tai se ei ole enää voimassa.", "unknown_entities.unknown_member": "Jäsentä ei löytynyt tästä yhteisöstä.", "unknown_entities.unknown_message": "Viestiä ei löytynyt.", "unknown_entities.unknown_role": "Roolia ei löytynyt.", + "unknown_entities.unknown_thread_member": "Ketjun jäsentä ei löytynyt.", "unknown_entities.unknown_user": "Käyttäjää ei löytynyt.", "unknown_entities.unknown_user_flag": "Määritettyä käyttäjälippua ei tunnisteta.", "urls.invalid_url_format": "Virheellinen URL-osoite.", @@ -491,8 +507,12 @@ const ERROR_I18N_FI_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Mukautetun URL-osoitteen koodin on oltava {min}–{max} merkkiä pitkä.", "vanity_urls.vanity_url_invalid_characters": "Mukautettu URL-osoite voi sisältää vain pieniä kirjaimia (a-z), numeroita (0-9) ja yhdysmerkkejä (-).", "webhooks.cannot_modify_system_webhook": "Järjestelmäviestejä ei voi muokata.", + "webhooks.forum_target_conflict": "Et voi antaa sekä kenttää `thread_id` että kenttää `thread_name`.", + "webhooks.forum_target_required": "Tälle kanavalle julkaisevat webhookit tarvitsevat kentän `thread_id` tai `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Kanavan webhookien enimmäismäärä on saavutettu: {count, plural, one {# webhook} other {# webhookia}}.", "webhooks.max_webhooks_per_guild_reached": "Yhteisön webhookien enimmäismäärä on saavutettu: {count, plural, one {# webhook} other {# webhookia}}.", + "webhooks.service_forum_unsupported": "Tämä webhook-palvelu ei voi julkaista foorumi- tai mediakanavalle ilman kenttää `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` ja `applied_tags` toimivat vain foorumi- ja mediakanavilla.", "webhooks.unknown_webhook": "Tuntematon webhook.", "webhooks.webhook_name_length_invalid": "Webhookin nimen on oltava {min}–{max} merkkiä pitkä." }); diff --git a/packages/errors/src/i18n/locales/fr.ts b/packages/errors/src/i18n/locales/fr.ts index 9e5b90df2..8eae0bfe0 100644 --- a/packages/errors/src/i18n/locales/fr.ts +++ b/packages/errors/src/i18n/locales/fr.ts @@ -449,16 +449,32 @@ const ERROR_I18N_FR_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Traitement du webhook indisponible.", "stripe.webhook_signature_invalid": "La signature du webhook Stripe est invalide.", "stripe.webhook_signature_missing": "La signature du webhook Stripe est manquante.", + "threads.channel_has_threads": "Les salons contenant des fils ne peuvent pas être convertis en salons d'annonces.", + "threads.forum_tag_names_must_be_unique": "Les noms des tags doivent être uniques.", + "threads.forum_tag_required": "Un tag est requis pour créer une publication dans ce salon.", + "threads.hide_media_download_option_media_only": "Le masquage des options de téléchargement des médias n'est disponible que dans les salons multimédias.", + "threads.invalid_thread_notification_settings": "Paramètres de notification du fil non valides.", + "threads.max_active_threads_reached": "Cette communauté a atteint sa limite de fils actifs : {count}.", + "threads.max_forum_tags_reached": "Ce salon a atteint sa limite de tags : {count}.", + "threads.max_pinned_threads_reached": "Ce salon a atteint sa limite de publications épinglées : {count}.", + "threads.max_thread_members_reached": "Ce fil a atteint sa limite de membres : {count}.", + "threads.no_tags_available_to_non_moderators": "Un tag est requis, mais seuls les modérateurs peuvent appliquer les tags de ce salon.", + "threads.search_index_not_ready": "L'index de recherche n'est pas encore prêt. Réessaie dans un instant.", + "threads.thread_already_created_for_message": "Un fil a déjà été créé pour ce message.", + "threads.thread_archived": "Ce fil est archivé.", + "threads.thread_locked": "Ce fil est verrouillé.", "tickets.ticket_already_completed": "Ce ticket a déjà été traité.", "tos_and_age.date_of_birth_format_invalid": "Format de date de naissance invalide.", "tos_and_age.must_agree_to_tos_and_privacy": "Vous devez accepter les conditions d’utilisation et la politique de confidentialité.", "tos_and_age.must_be_minimum_age": "Vous devez avoir au moins {minAge, plural, =1 {# an} other {# ans}} pour créer un compte.", "unknown_entities.unknown_channel": "Salon introuvable.", + "unknown_entities.unknown_forum_tag": "Tag introuvable.", "unknown_entities.unknown_guild": "Communauté introuvable.", "unknown_entities.unknown_invite": "Cette invitation est introuvable ou n’est plus valide.", "unknown_entities.unknown_member": "Membre introuvable dans cette communauté.", "unknown_entities.unknown_message": "Message introuvable.", "unknown_entities.unknown_role": "Rôle introuvable.", + "unknown_entities.unknown_thread_member": "Membre du fil introuvable.", "unknown_entities.unknown_user": "Utilisateur introuvable.", "unknown_entities.unknown_user_flag": "L’indicateur utilisateur spécifié n’est pas reconnu.", "urls.invalid_url_format": "Format d'URL invalide.", @@ -491,8 +507,12 @@ const ERROR_I18N_FR_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Le code d'URL personnalisée doit comporter entre {min} et {max} caractères.", "vanity_urls.vanity_url_invalid_characters": "L'URL personnalisée ne peut contenir que des lettres minuscules (a-z), des chiffres (0-9) et des tirets (-).", "webhooks.cannot_modify_system_webhook": "Vous ne pouvez pas modifier les messages système.", + "webhooks.forum_target_conflict": "Tu ne peux pas indiquer à la fois `thread_id` et `thread_name`.", + "webhooks.forum_target_required": "Les webhooks qui publient dans ce salon doivent fournir un `thread_id` ou un `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Vous avez atteint le maximum de {count, plural, =1 {# webhook} other {# webhooks}} par salon.", "webhooks.max_webhooks_per_guild_reached": "Vous avez atteint le maximum de {count, plural, =1 {# webhook} other {# webhooks}} par communauté.", + "webhooks.service_forum_unsupported": "Ce service de webhook ne peut pas publier dans un salon forum ou multimédia sans `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` et `applied_tags` ne fonctionnent que dans les salons forum et multimédias.", "webhooks.unknown_webhook": "Webhook inconnu.", "webhooks.webhook_name_length_invalid": "Le nom du webhook doit comporter entre {min} et {max} caractères." }); diff --git a/packages/errors/src/i18n/locales/he.ts b/packages/errors/src/i18n/locales/he.ts index 569268e1c..371b6ab07 100644 --- a/packages/errors/src/i18n/locales/he.ts +++ b/packages/errors/src/i18n/locales/he.ts @@ -449,16 +449,32 @@ const ERROR_I18N_HE_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "עיבוד ה-webhook אינו זמין.", "stripe.webhook_signature_invalid": "חתימת ה-webhook של Stripe שגויה.", "stripe.webhook_signature_missing": "חתימת ה-webhook של Stripe חסרה.", + "threads.channel_has_threads": "לא ניתן להמיר ערוצים עם שרשורים לערוצי הכרזות.", + "threads.forum_tag_names_must_be_unique": "שמות התגיות חייבים להיות ייחודיים.", + "threads.forum_tag_required": "כדי ליצור פוסט בערוץ הזה צריך תגית.", + "threads.hide_media_download_option_media_only": "הסתרת אפשרויות ההורדה של מדיה זמינה רק בערוצי מדיה.", + "threads.invalid_thread_notification_settings": "הגדרות ההתראות של השרשור אינן תקינות.", + "threads.max_active_threads_reached": "הקהילה הזו הגיעה למגבלת השרשורים הפעילים: {count}.", + "threads.max_forum_tags_reached": "הערוץ הזה הגיע למגבלת התגיות: {count}.", + "threads.max_pinned_threads_reached": "הערוץ הזה הגיע למגבלת הפוסטים המוצמדים: {count}.", + "threads.max_thread_members_reached": "השרשור הזה הגיע למגבלת החברים: {count}.", + "threads.no_tags_available_to_non_moderators": "צריך תגית, אבל בערוץ הזה רק מנחים יכולים להוסיף תגיות.", + "threads.search_index_not_ready": "אינדקס החיפוש עדיין לא מוכן. נסו שוב בעוד רגע.", + "threads.thread_already_created_for_message": "כבר נוצר שרשור להודעה הזו.", + "threads.thread_archived": "השרשור הזה בארכיון.", + "threads.thread_locked": "השרשור הזה נעול.", "tickets.ticket_already_completed": "כרטיס זה כבר הושלם.", "tos_and_age.date_of_birth_format_invalid": "פורמט תאריך לידה שגוי.", "tos_and_age.must_agree_to_tos_and_privacy": "יש להסכים לתנאי השירות ולמדיניות הפרטיות.", "tos_and_age.must_be_minimum_age": "יש להיות בגיל {minAge} {minAge, plural, one {שנה} other {שנים}} לפחות כדי ליצור חשבון.", "unknown_entities.unknown_channel": "הערוץ לא נמצא.", + "unknown_entities.unknown_forum_tag": "התגית לא נמצאה.", "unknown_entities.unknown_guild": "הקהילה לא נמצאה.", "unknown_entities.unknown_invite": "ההזמנה לא נמצאה או שאינה תקפה יותר.", "unknown_entities.unknown_member": "החבר לא נמצא בקהילה זו.", "unknown_entities.unknown_message": "ההודעה לא נמצאה.", "unknown_entities.unknown_role": "התפקיד לא נמצא.", + "unknown_entities.unknown_thread_member": "חבר השרשור לא נמצא.", "unknown_entities.unknown_user": "המשתמש לא נמצא.", "unknown_entities.unknown_user_flag": "דגל המשתמש שצוין אינו מזוהה.", "urls.invalid_url_format": "פורמט כתובת אתר שגוי.", @@ -491,8 +507,12 @@ const ERROR_I18N_HE_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "קוד כתובת ה-URL המותאמת אישית חייב להיות באורך של {min} עד {max} תווים.", "vanity_urls.vanity_url_invalid_characters": "כתובת ה-URL המותאמת אישית יכולה להכיל רק אותיות קטנות (a-z), ספרות (0-9) ומקפים (-).", "webhooks.cannot_modify_system_webhook": "לא ניתן לערוך הודעות מערכת.", + "webhooks.forum_target_conflict": "אי אפשר לציין גם `thread_id` וגם `thread_name`.", + "webhooks.forum_target_required": "webhooks שמפרסמים בערוץ הזה צריכים `thread_id` או `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "הגעת למספר המרבי של {count} {count, plural, one {webhook} other {webhooks}} לערוץ.", "webhooks.max_webhooks_per_guild_reached": "הגעת למספר המרבי של {count} {count, plural, one {webhook} other {webhooks}} לקהילה.", + "webhooks.service_forum_unsupported": "שירות ה-webhook הזה לא יכול לפרסם בערוץ פורום או מדיה בלי `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` ו-`applied_tags` עובדים רק בערוצי פורום ומדיה.", "webhooks.unknown_webhook": "webhook לא ידוע.", "webhooks.webhook_name_length_invalid": "שם ה-webhook חייב להיות באורך של {min} עד {max} תווים." }); diff --git a/packages/errors/src/i18n/locales/hi.ts b/packages/errors/src/i18n/locales/hi.ts index 0497b7125..3a16dba1f 100644 --- a/packages/errors/src/i18n/locales/hi.ts +++ b/packages/errors/src/i18n/locales/hi.ts @@ -449,16 +449,32 @@ const ERROR_I18N_HI_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "वेबहुक प्रोसेसिंग उपलब्ध नहीं है।", "stripe.webhook_signature_invalid": "Stripe वेबहुक सिग्नेचर अमान्य है।", "stripe.webhook_signature_missing": "Stripe वेबहुक सिग्नेचर मौजूद नहीं है।", + "threads.channel_has_threads": "थ्रेड वाले चैनल घोषणा चैनल में नहीं बदले जा सकते।", + "threads.forum_tag_names_must_be_unique": "टैग के नाम अलग-अलग होने चाहिए।", + "threads.forum_tag_required": "इस चैनल में पोस्ट बनाने के लिए एक टैग ज़रूरी है।", + "threads.hide_media_download_option_media_only": "मीडिया डाउनलोड विकल्प छिपाना केवल मीडिया चैनलों में उपलब्ध है।", + "threads.invalid_thread_notification_settings": "थ्रेड की सूचना सेटिंग्स अमान्य हैं।", + "threads.max_active_threads_reached": "यह कम्युनिटी सक्रिय थ्रेड की सीमा तक पहुँच गई है: {count}।", + "threads.max_forum_tags_reached": "यह चैनल टैग की सीमा तक पहुँच गया है: {count}।", + "threads.max_pinned_threads_reached": "यह चैनल पिन की गई पोस्ट की सीमा तक पहुँच गया है: {count}।", + "threads.max_thread_members_reached": "यह थ्रेड सदस्यों की सीमा तक पहुँच गया है: {count}।", + "threads.no_tags_available_to_non_moderators": "टैग ज़रूरी है, लेकिन इस चैनल में केवल मॉडरेटर ही टैग लगा सकते हैं।", + "threads.search_index_not_ready": "सर्च इंडेक्स अभी तैयार नहीं है। थोड़ी देर में फिर कोशिश करें।", + "threads.thread_already_created_for_message": "इस मैसेज के लिए पहले ही एक थ्रेड बनाया जा चुका है।", + "threads.thread_archived": "यह थ्रेड आर्काइव किया गया है।", + "threads.thread_locked": "यह थ्रेड लॉक है।", "tickets.ticket_already_completed": "यह टिकट पहले ही पूरा हो चुका है।", "tos_and_age.date_of_birth_format_invalid": "जन्म तिथि का फ़ॉर्मेट अमान्य है।", "tos_and_age.must_agree_to_tos_and_privacy": "आपको सेवा की शर्तों और गोपनीयता नीति से सहमत होना होगा।", "tos_and_age.must_be_minimum_age": "अकाउंट बनाने के लिए आपकी उम्र कम से कम {minAge} {minAge, plural, one {साल} other {साल}} होनी चाहिए।", "unknown_entities.unknown_channel": "चैनल नहीं मिला।", + "unknown_entities.unknown_forum_tag": "टैग नहीं मिला।", "unknown_entities.unknown_guild": "कम्युनिटी नहीं मिली।", "unknown_entities.unknown_invite": "इनवाइट नहीं मिला या अब मान्य नहीं है।", "unknown_entities.unknown_member": "इस कम्युनिटी में सदस्य नहीं मिला।", "unknown_entities.unknown_message": "मैसेज नहीं मिला।", "unknown_entities.unknown_role": "रोल नहीं मिला।", + "unknown_entities.unknown_thread_member": "थ्रेड सदस्य नहीं मिला।", "unknown_entities.unknown_user": "यूज़र नहीं मिला।", "unknown_entities.unknown_user_flag": "यह यूज़र फ़्लैग पहचाना नहीं गया।", "urls.invalid_url_format": "अमान्य URL फ़ॉर्मेट।", @@ -491,8 +507,12 @@ const ERROR_I18N_HI_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "वैनिटी URL कोड {min} और {max} कैरेक्टर के बीच होना चाहिए।", "vanity_urls.vanity_url_invalid_characters": "वैनिटी URL में केवल छोटे अक्षर (a-z), डिजिट (0-9) और हाइफ़न (-) हो सकते हैं।", "webhooks.cannot_modify_system_webhook": "आप सिस्टम मैसेज एडिट नहीं कर सकते।", + "webhooks.forum_target_conflict": "आप `thread_id` और `thread_name` दोनों एक साथ नहीं दे सकते।", + "webhooks.forum_target_required": "इस चैनल में पोस्ट करने वाले वेबहुक को `thread_id` या `thread_name` चाहिए।", "webhooks.max_webhooks_per_channel_reached": "आप एक चैनल के लिए अधिकतम {count} {count, plural, one {वेबहुक} other {वेबहुक}} तक पहुँच गए हैं।", "webhooks.max_webhooks_per_guild_reached": "आप एक कम्युनिटी के लिए अधिकतम {count} {count, plural, one {वेबहुक} other {वेबहुक}} तक पहुँच गए हैं।", + "webhooks.service_forum_unsupported": "यह वेबहुक सेवा `thread_id` के बिना फ़ोरम या मीडिया चैनल में पोस्ट नहीं कर सकती।", + "webhooks.thread_name_requires_forum": "`thread_name` और `applied_tags` केवल फ़ोरम और मीडिया चैनलों में काम करते हैं।", "webhooks.unknown_webhook": "अज्ञात वेबहुक।", "webhooks.webhook_name_length_invalid": "वेबहुक नेम {min} और {max} कैरेक्टर के बीच होना चाहिए।" }); diff --git a/packages/errors/src/i18n/locales/hr.ts b/packages/errors/src/i18n/locales/hr.ts index f9d4e1e8a..50fce7503 100644 --- a/packages/errors/src/i18n/locales/hr.ts +++ b/packages/errors/src/i18n/locales/hr.ts @@ -449,16 +449,32 @@ const ERROR_I18N_HR_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Obrada webhooka nije dostupna.", "stripe.webhook_signature_invalid": "Potpis Stripe webhooka je nevažeći.", "stripe.webhook_signature_missing": "Nedostaje potpis Stripe webhooka.", + "threads.channel_has_threads": "Kanali s nitima ne mogu se pretvoriti u kanale za obavijesti.", + "threads.forum_tag_names_must_be_unique": "Nazivi oznaka moraju biti jedinstveni.", + "threads.forum_tag_required": "Za objavu u ovom kanalu potrebna je oznaka.", + "threads.hide_media_download_option_media_only": "Skrivanje opcija preuzimanja medija dostupno je samo u medijskim kanalima.", + "threads.invalid_thread_notification_settings": "Nevažeće postavke obavijesti niti.", + "threads.max_active_threads_reached": "Ova zajednica dosegnula je ograničenje aktivnih niti: {count}.", + "threads.max_forum_tags_reached": "Ovaj kanal dosegnuo je ograničenje oznaka: {count}.", + "threads.max_pinned_threads_reached": "Ovaj kanal dosegnuo je ograničenje prikvačenih objava: {count}.", + "threads.max_thread_members_reached": "Ova nit dosegnula je ograničenje članova: {count}.", + "threads.no_tags_available_to_non_moderators": "Oznaka je obavezna, ali u ovom kanalu oznake mogu dodavati samo moderatori.", + "threads.search_index_not_ready": "Indeks pretraživanja još nije spreman. Pokušaj ponovno za trenutak.", + "threads.thread_already_created_for_message": "Za ovu poruku već je stvorena nit.", + "threads.thread_archived": "Ova nit je arhivirana.", + "threads.thread_locked": "Ova nit je zaključana.", "tickets.ticket_already_completed": "Ovaj je tiket već dovršen.", "tos_and_age.date_of_birth_format_invalid": "Nevažeći format datuma rođenja.", "tos_and_age.must_agree_to_tos_and_privacy": "Moraš prihvatiti Uvjete pružanja usluge i Pravila privatnosti.", "tos_and_age.must_be_minimum_age": "Moraš imati najmanje {minAge, plural, one {# godinu} few {# godine} other {# godina}} da otvoriš račun.", "unknown_entities.unknown_channel": "Kanal nije pronađen.", + "unknown_entities.unknown_forum_tag": "Oznaka nije pronađena.", "unknown_entities.unknown_guild": "Zajednica nije pronađena.", "unknown_entities.unknown_invite": "Pozivnica nije pronađena ili više nije važeća.", "unknown_entities.unknown_member": "Član nije pronađen u ovoj zajednici.", "unknown_entities.unknown_message": "Poruka nije pronađena.", "unknown_entities.unknown_role": "Uloga nije pronađena.", + "unknown_entities.unknown_thread_member": "Član niti nije pronađen.", "unknown_entities.unknown_user": "Korisnik nije pronađen.", "unknown_entities.unknown_user_flag": "Navedena korisnička zastavica nije prepoznata.", "urls.invalid_url_format": "Nevažeći format URL-a.", @@ -491,8 +507,12 @@ const ERROR_I18N_HR_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Kod prilagođenog URL-a mora biti između {min} i {max} znakova.", "vanity_urls.vanity_url_invalid_characters": "Prilagođeni URL može sadržavati samo mala slova (a-z), znamenke (0-9) i crtice (-).", "webhooks.cannot_modify_system_webhook": "Ne možeš uređivati sistemske poruke.", + "webhooks.forum_target_conflict": "Ne možeš navesti i `thread_id` i `thread_name`.", + "webhooks.forum_target_required": "Webhookovi koji objavljuju u ovom kanalu trebaju `thread_id` ili `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Dosegnut je maksimum od {count, plural, one {# webhooka} few {# webhooka} other {# webhookova}} po kanalu.", "webhooks.max_webhooks_per_guild_reached": "Dosegnut je maksimum od {count, plural, one {# webhooka} few {# webhooka} other {# webhookova}} po zajednici.", + "webhooks.service_forum_unsupported": "Ova webhook usluga ne može objavljivati u forumskom ili medijskom kanalu bez `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` i `applied_tags` rade samo u forumskim i medijskim kanalima.", "webhooks.unknown_webhook": "Nepoznat webhook.", "webhooks.webhook_name_length_invalid": "Naziv webhooka mora biti između {min} i {max} znakova." }); diff --git a/packages/errors/src/i18n/locales/hu.ts b/packages/errors/src/i18n/locales/hu.ts index c4da89701..4d278ac68 100644 --- a/packages/errors/src/i18n/locales/hu.ts +++ b/packages/errors/src/i18n/locales/hu.ts @@ -449,16 +449,32 @@ const ERROR_I18N_HU_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "A webhook feldolgozása nem működik.", "stripe.webhook_signature_invalid": "A Stripe-webhook aláírása érvénytelen.", "stripe.webhook_signature_missing": "A Stripe-webhook aláírása hiányzik.", + "threads.channel_has_threads": "A szálakat tartalmazó csatornák nem alakíthatók át bejelentési csatornává.", + "threads.forum_tag_names_must_be_unique": "A címkék nevének egyedinek kell lennie.", + "threads.forum_tag_required": "Ebben a csatornában bejegyzés létrehozásához címke szükséges.", + "threads.hide_media_download_option_media_only": "A médialetöltési lehetőségek elrejtése csak médiacsatornákban érhető el.", + "threads.invalid_thread_notification_settings": "Érvénytelen szálértesítési beállítások.", + "threads.max_active_threads_reached": "Ez a közösség elérte az aktív szálak korlátját: {count}.", + "threads.max_forum_tags_reached": "Ez a csatorna elérte a címkék korlátját: {count}.", + "threads.max_pinned_threads_reached": "Ez a csatorna elérte a kitűzött bejegyzések korlátját: {count}.", + "threads.max_thread_members_reached": "Ez a szál elérte a tagok korlátját: {count}.", + "threads.no_tags_available_to_non_moderators": "Címke szükséges, de ebben a csatornában csak moderátorok adhatnak hozzá címkét.", + "threads.search_index_not_ready": "A keresési index még nem áll készen. Próbáld újra egy pillanat múlva.", + "threads.thread_already_created_for_message": "Ehhez az üzenethez már létrehoztak egy szálat.", + "threads.thread_archived": "Ez a szál archiválva van.", + "threads.thread_locked": "Ez a szál zárolva van.", "tickets.ticket_already_completed": "Ezt a hibajegyet már lezárták.", "tos_and_age.date_of_birth_format_invalid": "A születési dátum formátuma érvénytelen.", "tos_and_age.must_agree_to_tos_and_privacy": "El kell fogadnod a Szolgáltatási feltételeket és az Adatvédelmi irányelveket.", "tos_and_age.must_be_minimum_age": "Legalább {minAge, plural, one {# évesnek} other {# évesnek}} kell lenned ahhoz, hogy fiókot hozz létre.", "unknown_entities.unknown_channel": "A csatorna nem található.", + "unknown_entities.unknown_forum_tag": "A címke nem található.", "unknown_entities.unknown_guild": "A közösség nem található.", "unknown_entities.unknown_invite": "A meghívó nem található, vagy már nem érvényes.", "unknown_entities.unknown_member": "A tag nem található ebben a közösségben.", "unknown_entities.unknown_message": "Az üzenet nem található.", "unknown_entities.unknown_role": "A szerepkör nem található.", + "unknown_entities.unknown_thread_member": "A szál tagja nem található.", "unknown_entities.unknown_user": "A felhasználó nem található.", "unknown_entities.unknown_user_flag": "A megadott felhasználói jelző nem ismert.", "urls.invalid_url_format": "Az URL formátuma érvénytelen.", @@ -491,8 +507,12 @@ const ERROR_I18N_HU_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Az egyéni meghívólink kódja {min}–{max} karakter hosszú lehet.", "vanity_urls.vanity_url_invalid_characters": "Az egyéni meghívólink csak kisbetűket (a-z), számokat (0-9) és kötőjeleket (-) tartalmazhat.", "webhooks.cannot_modify_system_webhook": "Rendszerüzeneteket nem szerkeszthetsz.", + "webhooks.forum_target_conflict": "Nem adhatod meg egyszerre a `thread_id` és a `thread_name` mezőt.", + "webhooks.forum_target_required": "Az ebbe a csatornába posztoló webhookoknak `thread_id` vagy `thread_name` szükséges.", "webhooks.max_webhooks_per_channel_reached": "Elérted a csatornánként megengedett webhookok számát ({count, plural, one {#} other {#}}).", "webhooks.max_webhooks_per_guild_reached": "Elérted a közösségenként megengedett webhookok számát ({count, plural, one {#} other {#}}).", + "webhooks.service_forum_unsupported": "Ez a webhookszolgáltatás `thread_id` nélkül nem tud fórum- vagy médiacsatornába posztolni.", + "webhooks.thread_name_requires_forum": "A `thread_name` és az `applied_tags` csak fórum- és médiacsatornákban működik.", "webhooks.unknown_webhook": "Ismeretlen webhook.", "webhooks.webhook_name_length_invalid": "A webhook neve {min}–{max} karakter hosszú lehet." }); diff --git a/packages/errors/src/i18n/locales/id.ts b/packages/errors/src/i18n/locales/id.ts index 9fa0d49dd..8d290b1b0 100644 --- a/packages/errors/src/i18n/locales/id.ts +++ b/packages/errors/src/i18n/locales/id.ts @@ -449,16 +449,32 @@ const ERROR_I18N_ID_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Pemrosesan webhook tidak tersedia.", "stripe.webhook_signature_invalid": "Tanda tangan webhook Stripe tidak valid.", "stripe.webhook_signature_missing": "Tanda tangan webhook Stripe tidak ada.", + "threads.channel_has_threads": "Saluran yang memiliki utas tidak bisa diubah menjadi saluran pengumuman.", + "threads.forum_tag_names_must_be_unique": "Nama tag harus unik.", + "threads.forum_tag_required": "Diperlukan tag untuk membuat postingan di saluran ini.", + "threads.hide_media_download_option_media_only": "Menyembunyikan opsi unduhan media hanya tersedia di saluran media.", + "threads.invalid_thread_notification_settings": "Pengaturan notifikasi utas tidak valid.", + "threads.max_active_threads_reached": "Komunitas ini sudah mencapai batas utas aktif: {count}.", + "threads.max_forum_tags_reached": "Saluran ini sudah mencapai batas tag: {count}.", + "threads.max_pinned_threads_reached": "Saluran ini sudah mencapai batas postingan yang disematkan: {count}.", + "threads.max_thread_members_reached": "Utas ini sudah mencapai batas anggota: {count}.", + "threads.no_tags_available_to_non_moderators": "Tag diperlukan, tetapi di saluran ini hanya moderator yang bisa menambahkan tag.", + "threads.search_index_not_ready": "Indeks pencarian belum siap. Coba lagi sebentar lagi.", + "threads.thread_already_created_for_message": "Utas untuk pesan ini sudah dibuat.", + "threads.thread_archived": "Utas ini diarsipkan.", + "threads.thread_locked": "Utas ini dikunci.", "tickets.ticket_already_completed": "Tiket ini sudah selesai.", "tos_and_age.date_of_birth_format_invalid": "Format tanggal lahir tidak valid.", "tos_and_age.must_agree_to_tos_and_privacy": "Kamu harus menyetujui Ketentuan layanan dan Kebijakan privasi.", "tos_and_age.must_be_minimum_age": "Kamu harus berusia minimal {minAge} {minAge, plural, other {tahun}} untuk membuat akun.", "unknown_entities.unknown_channel": "Saluran tidak ditemukan.", + "unknown_entities.unknown_forum_tag": "Tag tidak ditemukan.", "unknown_entities.unknown_guild": "Komunitas tidak ditemukan.", "unknown_entities.unknown_invite": "Undangan tidak ditemukan atau sudah tidak valid.", "unknown_entities.unknown_member": "Anggota tidak ditemukan di komunitas ini.", "unknown_entities.unknown_message": "Pesan tidak ditemukan.", "unknown_entities.unknown_role": "Peran tidak ditemukan.", + "unknown_entities.unknown_thread_member": "Anggota utas tidak ditemukan.", "unknown_entities.unknown_user": "Pengguna tidak ditemukan.", "unknown_entities.unknown_user_flag": "Flag pengguna yang ditentukan tidak dikenali.", "urls.invalid_url_format": "Format URL tidak valid.", @@ -491,8 +507,12 @@ const ERROR_I18N_ID_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Kode URL kustom harus antara {min} dan {max} karakter.", "vanity_urls.vanity_url_invalid_characters": "URL kustom hanya boleh berisi huruf kecil (a-z), angka (0-9), dan tanda hubung (-).", "webhooks.cannot_modify_system_webhook": "Kamu tidak bisa mengedit pesan sistem.", + "webhooks.forum_target_conflict": "Kamu tidak bisa menentukan `thread_id` dan `thread_name` sekaligus.", + "webhooks.forum_target_required": "Webhook yang memposting ke saluran ini memerlukan `thread_id` atau `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Kamu telah mencapai batas maksimal {count} {count, plural, other {webhook}} per saluran.", "webhooks.max_webhooks_per_guild_reached": "Kamu telah mencapai batas maksimal {count} {count, plural, other {webhook}} per komunitas.", + "webhooks.service_forum_unsupported": "Layanan webhook ini tidak bisa memposting ke saluran forum atau media tanpa `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` dan `applied_tags` hanya berfungsi di saluran forum dan media.", "webhooks.unknown_webhook": "Webhook tidak dikenal.", "webhooks.webhook_name_length_invalid": "Nama webhook harus antara {min} dan {max} karakter." }); diff --git a/packages/errors/src/i18n/locales/it.ts b/packages/errors/src/i18n/locales/it.ts index 8e837d7a6..6b4f10581 100644 --- a/packages/errors/src/i18n/locales/it.ts +++ b/packages/errors/src/i18n/locales/it.ts @@ -449,16 +449,32 @@ const ERROR_I18N_IT_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "L'elaborazione webhook non è disponibile.", "stripe.webhook_signature_invalid": "La firma del webhook Stripe non è valida.", "stripe.webhook_signature_missing": "Manca la firma del webhook Stripe.", + "threads.channel_has_threads": "I canali con thread non possono essere convertiti in canali annunci.", + "threads.forum_tag_names_must_be_unique": "I nomi dei tag devono essere univoci.", + "threads.forum_tag_required": "Per creare un post in questo canale serve un tag.", + "threads.hide_media_download_option_media_only": "Nascondere le opzioni di download dei media è possibile solo nei canali multimediali.", + "threads.invalid_thread_notification_settings": "Impostazioni di notifica del thread non valide.", + "threads.max_active_threads_reached": "Questa community ha raggiunto il limite di thread attivi: {count}.", + "threads.max_forum_tags_reached": "Questo canale ha raggiunto il limite di tag: {count}.", + "threads.max_pinned_threads_reached": "Questo canale ha raggiunto il limite di post fissati: {count}.", + "threads.max_thread_members_reached": "Questo thread ha raggiunto il limite di membri: {count}.", + "threads.no_tags_available_to_non_moderators": "Serve un tag, ma in questo canale solo i moderatori possono applicare i tag.", + "threads.search_index_not_ready": "L'indice di ricerca non è ancora pronto. Riprova tra un attimo.", + "threads.thread_already_created_for_message": "Per questo messaggio è già stato creato un thread.", + "threads.thread_archived": "Questo thread è archiviato.", + "threads.thread_locked": "Questo thread è bloccato.", "tickets.ticket_already_completed": "Questo ticket è già stato completato.", "tos_and_age.date_of_birth_format_invalid": "Formato data di nascita non valido.", "tos_and_age.must_agree_to_tos_and_privacy": "Devi accettare i Termini di servizio e l'Informativa sulla privacy.", "tos_and_age.must_be_minimum_age": "Devi avere almeno {minAge, plural, one {# anno} other {# anni}} per creare un account.", "unknown_entities.unknown_channel": "Canale non trovato.", + "unknown_entities.unknown_forum_tag": "Tag non trovato.", "unknown_entities.unknown_guild": "Community non trovata.", "unknown_entities.unknown_invite": "Invito non trovato o non più valido.", "unknown_entities.unknown_member": "Membro non trovato in questa community.", "unknown_entities.unknown_message": "Messaggio non trovato.", "unknown_entities.unknown_role": "Ruolo non trovato.", + "unknown_entities.unknown_thread_member": "Membro del thread non trovato.", "unknown_entities.unknown_user": "Utente non trovato.", "unknown_entities.unknown_user_flag": "Il flag utente specificato non è riconosciuto.", "urls.invalid_url_format": "Formato URL non valido.", @@ -491,8 +507,12 @@ const ERROR_I18N_IT_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Il codice dell’URL personalizzato deve contenere da {min} a {max} caratteri.", "vanity_urls.vanity_url_invalid_characters": "L'URL personalizzato può contenere solo lettere minuscole (a-z), cifre (0-9) e trattini (-).", "webhooks.cannot_modify_system_webhook": "Non puoi modificare i messaggi di sistema.", + "webhooks.forum_target_conflict": "Non puoi specificare sia `thread_id` sia `thread_name`.", + "webhooks.forum_target_required": "I webhook che pubblicano in questo canale devono indicare un `thread_id` o un `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Hai raggiunto il limite di {count, plural, one {# webhook} other {# webhook}} per canale.", "webhooks.max_webhooks_per_guild_reached": "Hai raggiunto il limite di {count, plural, one {# webhook} other {# webhook}} per community.", + "webhooks.service_forum_unsupported": "Questo servizio di webhook non può pubblicare in un canale forum o multimediale senza un `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` e `applied_tags` funzionano solo nei canali forum e multimediali.", "webhooks.unknown_webhook": "Webhook sconosciuto.", "webhooks.webhook_name_length_invalid": "Il nome del webhook deve contenere da {min} a {max} caratteri." }); diff --git a/packages/errors/src/i18n/locales/ja.ts b/packages/errors/src/i18n/locales/ja.ts index 1acda8d1d..22707169f 100644 --- a/packages/errors/src/i18n/locales/ja.ts +++ b/packages/errors/src/i18n/locales/ja.ts @@ -449,16 +449,32 @@ const ERROR_I18N_JA_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "ウェブフック処理は利用できません。", "stripe.webhook_signature_invalid": "Stripeのウェブフック署名が無効です。", "stripe.webhook_signature_missing": "Stripeのウェブフック署名がありません。", + "threads.channel_has_threads": "スレッドがあるチャンネルはアナウンスチャンネルに変換できません。", + "threads.forum_tag_names_must_be_unique": "タグ名は重複できません。", + "threads.forum_tag_required": "このチャンネルに投稿するにはタグが必要です。", + "threads.hide_media_download_option_media_only": "メディアのダウンロードオプションを非表示にできるのはメディアチャンネルだけです。", + "threads.invalid_thread_notification_settings": "スレッドの通知設定が無効です。", + "threads.max_active_threads_reached": "このコミュニティはアクティブなスレッドの上限に達しました: {count}", + "threads.max_forum_tags_reached": "このチャンネルはタグの上限に達しました: {count}", + "threads.max_pinned_threads_reached": "このチャンネルはピン留めされた投稿の上限に達しました: {count}", + "threads.max_thread_members_reached": "このスレッドはメンバーの上限に達しました: {count}", + "threads.no_tags_available_to_non_moderators": "タグが必要ですが、このチャンネルでタグを付けられるのはモデレーターだけです。", + "threads.search_index_not_ready": "検索インデックスの準備がまだできていません。少し待ってからもう一度お試しください。", + "threads.thread_already_created_for_message": "このメッセージのスレッドはすでに作成されています。", + "threads.thread_archived": "このスレッドはアーカイブされています。", + "threads.thread_locked": "このスレッドはロックされています。", "tickets.ticket_already_completed": "このチケットはすでに完了しています。", "tos_and_age.date_of_birth_format_invalid": "生年月日の形式が無効です。", "tos_and_age.must_agree_to_tos_and_privacy": "利用規約とプライバシーポリシーに同意する必要があります。", "tos_and_age.must_be_minimum_age": "アカウントを作成するには{minAge, plural, other {#歳}}以上である必要があります。", "unknown_entities.unknown_channel": "チャンネルが見つかりませんでした。", + "unknown_entities.unknown_forum_tag": "タグが見つかりませんでした。", "unknown_entities.unknown_guild": "コミュニティが見つかりませんでした。", "unknown_entities.unknown_invite": "招待が見つからないか、無効になっています。", "unknown_entities.unknown_member": "このコミュニティにメンバーが見つかりませんでした。", "unknown_entities.unknown_message": "メッセージが見つかりませんでした。", "unknown_entities.unknown_role": "ロールが見つかりませんでした。", + "unknown_entities.unknown_thread_member": "スレッドのメンバーが見つかりませんでした。", "unknown_entities.unknown_user": "ユーザーが見つかりませんでした。", "unknown_entities.unknown_user_flag": "指定されたユーザーフラグは認識されません。", "urls.invalid_url_format": "URLの形式が無効です。", @@ -491,8 +507,12 @@ const ERROR_I18N_JA_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "カスタムURLコードは{min}~{max}文字である必要があります。", "vanity_urls.vanity_url_invalid_characters": "カスタムURLには、小文字のラテン文字(a-z)、数字(0-9)、ハイフン(-)のみ使用できます。", "webhooks.cannot_modify_system_webhook": "システムメッセージは編集できません。", + "webhooks.forum_target_conflict": "`thread_id` と `thread_name` を同時に指定することはできません。", + "webhooks.forum_target_required": "このチャンネルに投稿するウェブフックには `thread_id` または `thread_name` が必要です。", "webhooks.max_webhooks_per_channel_reached": "このチャンネルでは、ウェブフックの上限{count, plural, other {#個}}に達しました。", "webhooks.max_webhooks_per_guild_reached": "このコミュニティでは、ウェブフックの上限{count, plural, other {#個}}に達しました。", + "webhooks.service_forum_unsupported": "このウェブフックサービスは `thread_id` なしではフォーラムチャンネルやメディアチャンネルに投稿できません。", + "webhooks.thread_name_requires_forum": "`thread_name` と `applied_tags` はフォーラムチャンネルとメディアチャンネルでのみ使えます。", "webhooks.unknown_webhook": "不明なウェブフックです。", "webhooks.webhook_name_length_invalid": "ウェブフック名は{min}~{max}文字である必要があります。" }); diff --git a/packages/errors/src/i18n/locales/ko.ts b/packages/errors/src/i18n/locales/ko.ts index 87f2ae79f..d7a9695a5 100644 --- a/packages/errors/src/i18n/locales/ko.ts +++ b/packages/errors/src/i18n/locales/ko.ts @@ -449,16 +449,32 @@ const ERROR_I18N_KO_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "웹훅 처리가 불가능해요.", "stripe.webhook_signature_invalid": "Stripe 웹훅 서명이 유효하지 않아요.", "stripe.webhook_signature_missing": "Stripe 웹훅 서명이 누락되었어요.", + "threads.channel_has_threads": "스레드가 있는 채널은 공지 채널로 전환할 수 없어요.", + "threads.forum_tag_names_must_be_unique": "태그 이름은 중복될 수 없어요.", + "threads.forum_tag_required": "이 채널에 게시물을 만들려면 태그가 필요해요.", + "threads.hide_media_download_option_media_only": "미디어 다운로드 옵션 숨기기는 미디어 채널에서만 사용할 수 있어요.", + "threads.invalid_thread_notification_settings": "스레드 알림 설정이 올바르지 않아요.", + "threads.max_active_threads_reached": "이 커뮤니티가 활성 스레드 한도에 도달했어요: {count}", + "threads.max_forum_tags_reached": "이 채널이 태그 한도에 도달했어요: {count}", + "threads.max_pinned_threads_reached": "이 채널이 고정된 게시물 한도에 도달했어요: {count}", + "threads.max_thread_members_reached": "이 스레드가 멤버 한도에 도달했어요: {count}", + "threads.no_tags_available_to_non_moderators": "태그가 필요하지만 이 채널에서는 모더레이터만 태그를 달 수 있어요.", + "threads.search_index_not_ready": "검색 인덱스가 아직 준비되지 않았어요. 잠시 후 다시 시도해 주세요.", + "threads.thread_already_created_for_message": "이 메시지에 대한 스레드가 이미 만들어졌어요.", + "threads.thread_archived": "이 스레드는 보관되었어요.", + "threads.thread_locked": "이 스레드는 잠겨 있어요.", "tickets.ticket_already_completed": "이 티켓은 이미 처리되었어요.", "tos_and_age.date_of_birth_format_invalid": "생년월일 형식이 올바르지 않아요.", "tos_and_age.must_agree_to_tos_and_privacy": "이용약관과 개인정보처리방침에 동의해 주세요.", "tos_and_age.must_be_minimum_age": "계정을 만들려면 만 {minAge, plural, other {#세}} 이상이어야 해요.", "unknown_entities.unknown_channel": "채널을 찾을 수 없어요.", + "unknown_entities.unknown_forum_tag": "태그를 찾을 수 없어요.", "unknown_entities.unknown_guild": "커뮤니티를 찾을 수 없어요.", "unknown_entities.unknown_invite": "초대를 찾을 수 없거나 더 이상 유효하지 않아요.", "unknown_entities.unknown_member": "이 커뮤니티에서 멤버를 찾을 수 없어요.", "unknown_entities.unknown_message": "메시지를 찾을 수 없어요.", "unknown_entities.unknown_role": "역할을 찾을 수 없어요.", + "unknown_entities.unknown_thread_member": "스레드 멤버를 찾을 수 없어요.", "unknown_entities.unknown_user": "사용자를 찾을 수 없어요.", "unknown_entities.unknown_user_flag": "지정된 사용자 플래그를 인식하지 못했어요.", "urls.invalid_url_format": "URL 형식이 올바르지 않아요.", @@ -491,8 +507,12 @@ const ERROR_I18N_KO_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "맞춤 URL 코드는 {min}자에서 {max}자 사이여야 해요.", "vanity_urls.vanity_url_invalid_characters": "맞춤 URL은 소문자(a-z), 숫자(0-9), 하이픈(-)만 사용할 수 있어요.", "webhooks.cannot_modify_system_webhook": "시스템 메시지는 수정할 수 없어요.", + "webhooks.forum_target_conflict": "`thread_id`와 `thread_name`을 함께 지정할 수 없어요.", + "webhooks.forum_target_required": "이 채널에 게시하는 웹훅에는 `thread_id` 또는 `thread_name`이 필요해요.", "webhooks.max_webhooks_per_channel_reached": "채널당 최대 {count, plural, other {#개}}의 웹훅을 만들 수 있어요.", "webhooks.max_webhooks_per_guild_reached": "커뮤니티당 최대 {count, plural, other {#개}}의 웹훅을 만들 수 있어요.", + "webhooks.service_forum_unsupported": "이 웹훅 서비스는 `thread_id` 없이 포럼 채널이나 미디어 채널에 게시할 수 없어요.", + "webhooks.thread_name_requires_forum": "`thread_name`과 `applied_tags`는 포럼 채널과 미디어 채널에서만 작동해요.", "webhooks.unknown_webhook": "알 수 없는 웹훅이에요.", "webhooks.webhook_name_length_invalid": "웹훅 이름은 {min}자에서 {max}자 사이여야 해요." }); diff --git a/packages/errors/src/i18n/locales/lt.ts b/packages/errors/src/i18n/locales/lt.ts index 35e030e8b..5d593b38d 100644 --- a/packages/errors/src/i18n/locales/lt.ts +++ b/packages/errors/src/i18n/locales/lt.ts @@ -449,16 +449,32 @@ const ERROR_I18N_LT_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Webhookų apdorojimas nepasiekiamas.", "stripe.webhook_signature_invalid": "Stripe webhooko parašas neteisingas.", "stripe.webhook_signature_missing": "Trūksta Stripe webhooko parašo.", + "threads.channel_has_threads": "Kanalų su gijomis negalima paversti pranešimų kanalais.", + "threads.forum_tag_names_must_be_unique": "Žymų pavadinimai turi būti unikalūs.", + "threads.forum_tag_required": "Norint sukurti įrašą šiame kanale, reikia žymos.", + "threads.hide_media_download_option_media_only": "Medijos atsisiuntimo parinktis galima paslėpti tik medijos kanaluose.", + "threads.invalid_thread_notification_settings": "Netinkami gijos pranešimų nustatymai.", + "threads.max_active_threads_reached": "Ši bendruomenė pasiekė aktyvių gijų ribą: {count}.", + "threads.max_forum_tags_reached": "Šis kanalas pasiekė žymų ribą: {count}.", + "threads.max_pinned_threads_reached": "Šis kanalas pasiekė prisegtų įrašų ribą: {count}.", + "threads.max_thread_members_reached": "Ši gija pasiekė narių ribą: {count}.", + "threads.no_tags_available_to_non_moderators": "Žyma privaloma, bet šiame kanale žymas gali pridėti tik moderatoriai.", + "threads.search_index_not_ready": "Paieškos indeksas dar neparuoštas. Pabandyk dar kartą po akimirkos.", + "threads.thread_already_created_for_message": "Šiai žinutei gija jau sukurta.", + "threads.thread_archived": "Ši gija suarchyvuota.", + "threads.thread_locked": "Ši gija užrakinta.", "tickets.ticket_already_completed": "Šis bilietas jau užbaigtas.", "tos_and_age.date_of_birth_format_invalid": "Netinkamas gimimo datos formatas.", "tos_and_age.must_agree_to_tos_and_privacy": "Privalai sutikti su paslaugų teikimo sąlygomis ir privatumo politika.", "tos_and_age.must_be_minimum_age": "Kad susikurtum paskyrą, turi būti bent {minAge, plural, one {# metų} few {# metų} many {# metų} other {# metų}}.", "unknown_entities.unknown_channel": "Kanalas nerastas.", + "unknown_entities.unknown_forum_tag": "Žyma nerasta.", "unknown_entities.unknown_guild": "Bendruomenė nerasta.", "unknown_entities.unknown_invite": "Kvietimas nerastas arba nebegalioja.", "unknown_entities.unknown_member": "Narys nerastas šioje bendruomenėje.", "unknown_entities.unknown_message": "Žinutė nerasta.", "unknown_entities.unknown_role": "Vaidmuo nerastas.", + "unknown_entities.unknown_thread_member": "Gijos narys nerastas.", "unknown_entities.unknown_user": "Naudotojas nerastas.", "unknown_entities.unknown_user_flag": "Nurodyta naudotojo vėliavėlė neatpažįstama.", "urls.invalid_url_format": "Netinkamas URL formatas.", @@ -491,8 +507,12 @@ const ERROR_I18N_LT_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Asmeninio URL kodas turi būti {min}–{max} simbolių.", "vanity_urls.vanity_url_invalid_characters": "Asmeniniame URL gali būti tik mažosios raidės (a-z), skaitmenys (0-9) ir brūkšneliai (-).", "webhooks.cannot_modify_system_webhook": "Negali redaguoti sistemos žinučių.", + "webhooks.forum_target_conflict": "Negali nurodyti ir `thread_id`, ir `thread_name`.", + "webhooks.forum_target_required": "Webhookams, kurie skelbia šiame kanale, reikia `thread_id` arba `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Pasiekei {count, plural, one {# webhooko} few {# webhookų} many {# webhooko} other {# webhookų}} ribą viename kanale.", "webhooks.max_webhooks_per_guild_reached": "Pasiekei {count, plural, one {# webhooko} few {# webhookų} many {# webhooko} other {# webhookų}} ribą vienoje bendruomenėje.", + "webhooks.service_forum_unsupported": "Ši webhookų paslauga negali skelbti forumo ar medijos kanale be `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` ir `applied_tags` veikia tik forumo ir medijos kanaluose.", "webhooks.unknown_webhook": "Nežinomas webhookas.", "webhooks.webhook_name_length_invalid": "Webhooko pavadinimas turi būti {min}–{max} simbolių." }); diff --git a/packages/errors/src/i18n/locales/nl.ts b/packages/errors/src/i18n/locales/nl.ts index f3371f2fc..34a019781 100644 --- a/packages/errors/src/i18n/locales/nl.ts +++ b/packages/errors/src/i18n/locales/nl.ts @@ -449,16 +449,32 @@ const ERROR_I18N_NL_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Webhookverwerking is niet beschikbaar.", "stripe.webhook_signature_invalid": "Stripe-webhookhandtekening is ongeldig.", "stripe.webhook_signature_missing": "Stripe-webhookhandtekening ontbreekt.", + "threads.channel_has_threads": "Kanalen met threads kunnen niet worden omgezet naar aankondigingskanalen.", + "threads.forum_tag_names_must_be_unique": "Tagnamen moeten uniek zijn.", + "threads.forum_tag_required": "Je hebt een tag nodig om een bericht in dit kanaal te plaatsen.", + "threads.hide_media_download_option_media_only": "Downloadopties voor media verbergen kan alleen in mediakanalen.", + "threads.invalid_thread_notification_settings": "Ongeldige meldingsinstellingen voor de thread.", + "threads.max_active_threads_reached": "Deze community heeft de limiet voor actieve threads bereikt: {count}.", + "threads.max_forum_tags_reached": "Dit kanaal heeft de taglimiet bereikt: {count}.", + "threads.max_pinned_threads_reached": "Dit kanaal heeft de limiet voor vastgezette berichten bereikt: {count}.", + "threads.max_thread_members_reached": "Deze thread heeft de ledenlimiet bereikt: {count}.", + "threads.no_tags_available_to_non_moderators": "Een tag is verplicht, maar in dit kanaal kunnen alleen moderators tags toevoegen.", + "threads.search_index_not_ready": "De zoekindex is nog niet klaar. Probeer het zo opnieuw.", + "threads.thread_already_created_for_message": "Er is al een thread aangemaakt voor dit bericht.", + "threads.thread_archived": "Deze thread is gearchiveerd.", + "threads.thread_locked": "Deze thread is vergrendeld.", "tickets.ticket_already_completed": "Dit ticket is al afgerond.", "tos_and_age.date_of_birth_format_invalid": "Ongeldig geboortedatumformaat.", "tos_and_age.must_agree_to_tos_and_privacy": "Je moet akkoord gaan met de Gebruiksvoorwaarden en het Privacybeleid.", "tos_and_age.must_be_minimum_age": "Je moet minstens {minAge, plural, one {# jaar} other {# jaar}} oud zijn om een account aan te maken.", "unknown_entities.unknown_channel": "Kanaal niet gevonden.", + "unknown_entities.unknown_forum_tag": "Tag niet gevonden.", "unknown_entities.unknown_guild": "Community niet gevonden.", "unknown_entities.unknown_invite": "De uitnodiging is niet gevonden of is niet meer geldig.", "unknown_entities.unknown_member": "Het lid is niet gevonden in deze community.", "unknown_entities.unknown_message": "Bericht niet gevonden.", "unknown_entities.unknown_role": "Rol niet gevonden.", + "unknown_entities.unknown_thread_member": "Threadlid niet gevonden.", "unknown_entities.unknown_user": "Gebruiker niet gevonden.", "unknown_entities.unknown_user_flag": "De opgegeven gebruikersvlag is niet herkend.", "urls.invalid_url_format": "Ongeldig URL-formaat.", @@ -491,8 +507,12 @@ const ERROR_I18N_NL_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "De code voor de aangepaste URL moet tussen {min} en {max} tekens lang zijn.", "vanity_urls.vanity_url_invalid_characters": "De aangepaste URL mag alleen kleine letters (a-z), cijfers (0-9) en koppeltekens (-) bevatten.", "webhooks.cannot_modify_system_webhook": "Systeemberichten kunnen niet worden bewerkt.", + "webhooks.forum_target_conflict": "Je kunt niet zowel `thread_id` als `thread_name` opgeven.", + "webhooks.forum_target_required": "Webhooks die in dit kanaal posten, hebben een `thread_id` of een `thread_name` nodig.", "webhooks.max_webhooks_per_channel_reached": "Je hebt het maximum van {count, plural, one {# webhook} other {# webhooks}} per kanaal bereikt.", "webhooks.max_webhooks_per_guild_reached": "Je hebt het maximum van {count, plural, one {# webhook} other {# webhooks}} per community bereikt.", + "webhooks.service_forum_unsupported": "Deze webhookdienst kan zonder `thread_id` niet posten in een forum- of mediakanaal.", + "webhooks.thread_name_requires_forum": "`thread_name` en `applied_tags` werken alleen in forum- en mediakanalen.", "webhooks.unknown_webhook": "Onbekende webhook.", "webhooks.webhook_name_length_invalid": "Webhooknaam moet tussen {min} en {max} tekens lang zijn." }); diff --git a/packages/errors/src/i18n/locales/no.ts b/packages/errors/src/i18n/locales/no.ts index d7e21a5f4..558826903 100644 --- a/packages/errors/src/i18n/locales/no.ts +++ b/packages/errors/src/i18n/locales/no.ts @@ -449,16 +449,32 @@ const ERROR_I18N_NO_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Behandling av webhooks er ikke tilgjengelig.", "stripe.webhook_signature_invalid": "Stripe-webhook-signaturen er ugyldig.", "stripe.webhook_signature_missing": "Stripe-webhook-signaturen mangler.", + "threads.channel_has_threads": "Kanaler med tråder kan ikke gjøres om til kunngjøringskanaler.", + "threads.forum_tag_names_must_be_unique": "Tagnavn må være unike.", + "threads.forum_tag_required": "Du trenger en tagg for å lage et innlegg i denne kanalen.", + "threads.hide_media_download_option_media_only": "Det er bare i mediekanaler at nedlastingsvalg for medier kan skjules.", + "threads.invalid_thread_notification_settings": "Ugyldige varslingsinnstillinger for tråden.", + "threads.max_active_threads_reached": "Dette fellesskapet har nådd grensen for aktive tråder: {count}.", + "threads.max_forum_tags_reached": "Denne kanalen har nådd grensen for tagger: {count}.", + "threads.max_pinned_threads_reached": "Denne kanalen har nådd grensen for festede innlegg: {count}.", + "threads.max_thread_members_reached": "Denne tråden har nådd grensen for medlemmer: {count}.", + "threads.no_tags_available_to_non_moderators": "Det kreves en tagg, men i denne kanalen er det bare moderatorer som kan legge til tagger.", + "threads.search_index_not_ready": "Søkeindeksen er ikke klar ennå. Prøv igjen om et øyeblikk.", + "threads.thread_already_created_for_message": "Det er allerede opprettet en tråd for denne meldingen.", + "threads.thread_archived": "Denne tråden er arkivert.", + "threads.thread_locked": "Denne tråden er låst.", "tickets.ticket_already_completed": "Denne saken er allerede fullført.", "tos_and_age.date_of_birth_format_invalid": "Ugyldig format for fødselsdato.", "tos_and_age.must_agree_to_tos_and_privacy": "Du må godta vilkårene for bruk og personvernreglene.", "tos_and_age.must_be_minimum_age": "Du må være minst {minAge, plural, one {# år} other {# år}} for å opprette en konto.", "unknown_entities.unknown_channel": "Kanalen ble ikke funnet.", + "unknown_entities.unknown_forum_tag": "Fant ikke taggen.", "unknown_entities.unknown_guild": "Fellesskapet ble ikke funnet.", "unknown_entities.unknown_invite": "Invitasjonen ble ikke funnet eller er ikke lenger gyldig.", "unknown_entities.unknown_member": "Medlemmet ble ikke funnet i dette fellesskapet.", "unknown_entities.unknown_message": "Meldingen ble ikke funnet.", "unknown_entities.unknown_role": "Rollen ble ikke funnet.", + "unknown_entities.unknown_thread_member": "Fant ikke trådmedlemmet.", "unknown_entities.unknown_user": "Brukeren ble ikke funnet.", "unknown_entities.unknown_user_flag": "Det angitte brukerflagget gjenkjennes ikke.", "urls.invalid_url_format": "Ugyldig URL-format.", @@ -491,8 +507,12 @@ const ERROR_I18N_NO_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Koden for den egendefinerte URL-en må inneholde mellom {min} og {max} tegn.", "vanity_urls.vanity_url_invalid_characters": "Den egendefinerte URL-en kan bare inneholde små bokstaver (a-z), sifre (0-9) og bindestreker (-).", "webhooks.cannot_modify_system_webhook": "Du kan ikke redigere systemmeldinger.", + "webhooks.forum_target_conflict": "Du kan ikke oppgi både `thread_id` og `thread_name`.", + "webhooks.forum_target_required": "Webhooks som poster i denne kanalen, må ha en `thread_id` eller et `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Du har nådd grensen på {count, plural, one {# webhook} other {# webhooks}} per kanal.", "webhooks.max_webhooks_per_guild_reached": "Du har nådd grensen på {count, plural, one {# webhook} other {# webhooks}} per fellesskap.", + "webhooks.service_forum_unsupported": "Denne webhooktjenesten kan ikke poste i en forum- eller mediekanal uten en `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` og `applied_tags` fungerer bare i forum- og mediekanaler.", "webhooks.unknown_webhook": "Ukjent webhook.", "webhooks.webhook_name_length_invalid": "Webhook-navnet må inneholde mellom {min} og {max} tegn." }); diff --git a/packages/errors/src/i18n/locales/pl.ts b/packages/errors/src/i18n/locales/pl.ts index 6454434eb..09e612c60 100644 --- a/packages/errors/src/i18n/locales/pl.ts +++ b/packages/errors/src/i18n/locales/pl.ts @@ -449,16 +449,32 @@ const ERROR_I18N_PL_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Przetwarzanie webhooków nie jest dostępne.", "stripe.webhook_signature_invalid": "Podpis webhooka Stripe jest nieprawidłowy.", "stripe.webhook_signature_missing": "Brak podpisu webhooka Stripe.", + "threads.channel_has_threads": "Kanałów z wątkami nie można przekształcić w kanały ogłoszeń.", + "threads.forum_tag_names_must_be_unique": "Nazwy tagów muszą być unikalne.", + "threads.forum_tag_required": "Aby utworzyć post na tym kanale, potrzebny jest tag.", + "threads.hide_media_download_option_media_only": "Ukrywanie opcji pobierania multimediów jest dostępne tylko na kanałach multimediów.", + "threads.invalid_thread_notification_settings": "Nieprawidłowe ustawienia powiadomień wątku.", + "threads.max_active_threads_reached": "Ta społeczność osiągnęła limit aktywnych wątków: {count}.", + "threads.max_forum_tags_reached": "Ten kanał osiągnął limit tagów: {count}.", + "threads.max_pinned_threads_reached": "Ten kanał osiągnął limit przypiętych postów: {count}.", + "threads.max_thread_members_reached": "Ten wątek osiągnął limit członków: {count}.", + "threads.no_tags_available_to_non_moderators": "Tag jest wymagany, ale na tym kanale tylko moderatorzy mogą dodawać tagi.", + "threads.search_index_not_ready": "Indeks wyszukiwania nie jest jeszcze gotowy. Spróbuj ponownie za chwilę.", + "threads.thread_already_created_for_message": "Dla tej wiadomości utworzono już wątek.", + "threads.thread_archived": "Ten wątek jest zarchiwizowany.", + "threads.thread_locked": "Ten wątek jest zablokowany.", "tickets.ticket_already_completed": "To zgłoszenie zostało już zakończone.", "tos_and_age.date_of_birth_format_invalid": "Nieprawidłowy format daty urodzenia.", "tos_and_age.must_agree_to_tos_and_privacy": "Musisz zaakceptować warunki korzystania z usługi oraz politykę prywatności.", "tos_and_age.must_be_minimum_age": "Musisz mieć co najmniej {minAge, plural, one {# rok} few {# lata} many {# lat} other {# roku}}, aby utworzyć konto.", "unknown_entities.unknown_channel": "Nie znaleziono kanału.", + "unknown_entities.unknown_forum_tag": "Nie znaleziono tagu.", "unknown_entities.unknown_guild": "Nie znaleziono społeczności.", "unknown_entities.unknown_invite": "Nie znaleziono zaproszenia lub jest już nieaktualne.", "unknown_entities.unknown_member": "Nie znaleziono członka w tej społeczności.", "unknown_entities.unknown_message": "Nie znaleziono wiadomości.", "unknown_entities.unknown_role": "Nie znaleziono roli.", + "unknown_entities.unknown_thread_member": "Nie znaleziono członka wątku.", "unknown_entities.unknown_user": "Nie znaleziono użytkownika.", "unknown_entities.unknown_user_flag": "Określona flaga użytkownika nie jest rozpoznawana.", "urls.invalid_url_format": "Nieprawidłowy format adresu URL.", @@ -491,8 +507,12 @@ const ERROR_I18N_PL_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Kod niestandardowego adresu URL musi mieć od {min} do {max} znaków.", "vanity_urls.vanity_url_invalid_characters": "Niestandardowy adres URL może zawierać tylko małe litery (a-z), cyfry (0-9) i łączniki (-).", "webhooks.cannot_modify_system_webhook": "Nie możesz edytować wiadomości systemowych.", + "webhooks.forum_target_conflict": "Nie możesz podać jednocześnie `thread_id` i `thread_name`.", + "webhooks.forum_target_required": "Webhooki publikujące na tym kanale muszą podać `thread_id` lub `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Osiągnięto limit {count, plural, one {# webhooka} few {# webhooków} many {# webhooków} other {# webhooka}} na kanał.", "webhooks.max_webhooks_per_guild_reached": "Osiągnięto limit {count, plural, one {# webhooka} few {# webhooków} many {# webhooków} other {# webhooka}} na społeczność.", + "webhooks.service_forum_unsupported": "Ta usługa webhooków nie może publikować na kanale forum ani multimediów bez `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` i `applied_tags` działają tylko na kanałach forum i multimediów.", "webhooks.unknown_webhook": "Nieznany webhook.", "webhooks.webhook_name_length_invalid": "Nazwa webhooka musi mieć od {min} do {max} znaków." }); diff --git a/packages/errors/src/i18n/locales/pt-BR.ts b/packages/errors/src/i18n/locales/pt-BR.ts index 2c1fda248..5ef215f97 100644 --- a/packages/errors/src/i18n/locales/pt-BR.ts +++ b/packages/errors/src/i18n/locales/pt-BR.ts @@ -449,16 +449,32 @@ const ERROR_I18N_PT_BR_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "O processamento de webhook não está disponível.", "stripe.webhook_signature_invalid": "A assinatura do webhook do Stripe é inválida.", "stripe.webhook_signature_missing": "A assinatura do webhook do Stripe está ausente.", + "threads.channel_has_threads": "Canais com tópicos não podem ser convertidos em canais de anúncios.", + "threads.forum_tag_names_must_be_unique": "Os nomes das tags precisam ser únicos.", + "threads.forum_tag_required": "É preciso uma tag para criar uma publicação neste canal.", + "threads.hide_media_download_option_media_only": "Ocultar as opções de download de mídia só está disponível em canais de mídia.", + "threads.invalid_thread_notification_settings": "Configurações de notificação do tópico inválidas.", + "threads.max_active_threads_reached": "Esta comunidade atingiu o limite de tópicos ativos: {count}.", + "threads.max_forum_tags_reached": "Este canal atingiu o limite de tags: {count}.", + "threads.max_pinned_threads_reached": "Este canal atingiu o limite de publicações fixadas: {count}.", + "threads.max_thread_members_reached": "Este tópico atingiu o limite de membros: {count}.", + "threads.no_tags_available_to_non_moderators": "É preciso uma tag, mas só moderadores podem aplicar as tags deste canal.", + "threads.search_index_not_ready": "O índice de pesquisa ainda não está pronto. Tente de novo em instantes.", + "threads.thread_already_created_for_message": "Já foi criado um tópico para esta mensagem.", + "threads.thread_archived": "Este tópico está arquivado.", + "threads.thread_locked": "Este tópico está trancado.", "tickets.ticket_already_completed": "Este ticket já foi concluído.", "tos_and_age.date_of_birth_format_invalid": "Formato de data de nascimento inválido.", "tos_and_age.must_agree_to_tos_and_privacy": "Você precisa concordar com os Termos de serviço e a Política de privacidade.", "tos_and_age.must_be_minimum_age": "Você precisa ter pelo menos {minAge, plural, =1 {# ano} other {# anos}} para criar uma conta.", "unknown_entities.unknown_channel": "Canal não encontrado.", + "unknown_entities.unknown_forum_tag": "Tag não encontrada.", "unknown_entities.unknown_guild": "Comunidade não encontrada.", "unknown_entities.unknown_invite": "Convite não encontrado ou não é mais válido.", "unknown_entities.unknown_member": "Membro não encontrado nesta comunidade.", "unknown_entities.unknown_message": "Mensagem não encontrada.", "unknown_entities.unknown_role": "Cargo não encontrado.", + "unknown_entities.unknown_thread_member": "Membro do tópico não encontrado.", "unknown_entities.unknown_user": "Usuário não encontrado.", "unknown_entities.unknown_user_flag": "A flag de usuário especificada não é reconhecida.", "urls.invalid_url_format": "Formato de URL inválido.", @@ -491,8 +507,12 @@ const ERROR_I18N_PT_BR_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "O código da URL personalizada deve ter entre {min} e {max} caracteres.", "vanity_urls.vanity_url_invalid_characters": "A URL personalizada só pode conter letras minúsculas (a-z), dígitos (0-9) e hífens (-).", "webhooks.cannot_modify_system_webhook": "Você não pode editar mensagens do sistema.", + "webhooks.forum_target_conflict": "Você não pode informar `thread_id` e `thread_name` ao mesmo tempo.", + "webhooks.forum_target_required": "Webhooks que publicam neste canal precisam de um `thread_id` ou de um `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Você atingiu o máximo de {count, plural, =1 {# webhook} other {# webhooks}} por canal.", "webhooks.max_webhooks_per_guild_reached": "Você atingiu o máximo de {count, plural, =1 {# webhook} other {# webhooks}} por comunidade.", + "webhooks.service_forum_unsupported": "Este serviço de webhook não consegue publicar em um canal de fórum ou de mídia sem um `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` e `applied_tags` só funcionam em canais de fórum e de mídia.", "webhooks.unknown_webhook": "Webhook desconhecido.", "webhooks.webhook_name_length_invalid": "O nome do webhook deve ter entre {min} e {max} caracteres." }); diff --git a/packages/errors/src/i18n/locales/ro.ts b/packages/errors/src/i18n/locales/ro.ts index d6db04b23..98fd5e094 100644 --- a/packages/errors/src/i18n/locales/ro.ts +++ b/packages/errors/src/i18n/locales/ro.ts @@ -449,16 +449,32 @@ const ERROR_I18N_RO_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Procesarea webhook-urilor nu este disponibilă.", "stripe.webhook_signature_invalid": "Semnătura webhook Stripe este invalidă.", "stripe.webhook_signature_missing": "Lipsește semnătura webhook Stripe.", + "threads.channel_has_threads": "Canalele cu fire nu pot fi transformate în canale de anunțuri.", + "threads.forum_tag_names_must_be_unique": "Numele etichetelor trebuie să fie unice.", + "threads.forum_tag_required": "Pentru a crea o postare în acest canal este nevoie de o etichetă.", + "threads.hide_media_download_option_media_only": "Ascunderea opțiunilor de descărcare media este disponibilă doar în canalele media.", + "threads.invalid_thread_notification_settings": "Setări de notificare pentru fir nevalide.", + "threads.max_active_threads_reached": "Această comunitate a atins limita de fire active: {count}.", + "threads.max_forum_tags_reached": "Acest canal a atins limita de etichete: {count}.", + "threads.max_pinned_threads_reached": "Acest canal a atins limita de postări fixate: {count}.", + "threads.max_thread_members_reached": "Acest fir a atins limita de membri: {count}.", + "threads.no_tags_available_to_non_moderators": "Este nevoie de o etichetă, dar în acest canal doar moderatorii pot aplica etichete.", + "threads.search_index_not_ready": "Indexul de căutare nu este încă pregătit. Încearcă din nou peste câteva clipe.", + "threads.thread_already_created_for_message": "Pentru acest mesaj a fost deja creat un fir.", + "threads.thread_archived": "Acest fir este arhivat.", + "threads.thread_locked": "Acest fir este blocat.", "tickets.ticket_already_completed": "Acest tichet a fost deja finalizat.", "tos_and_age.date_of_birth_format_invalid": "Formatul datei de naștere este invalid.", "tos_and_age.must_agree_to_tos_and_privacy": "Trebuie să accepți Termenii și condițiile, precum și Politica de confidențialitate.", "tos_and_age.must_be_minimum_age": "Trebuie să ai cel puțin {minAge, plural, one {# an} few {# ani} other {# de ani}} pentru a-ți crea un cont.", "unknown_entities.unknown_channel": "Canalul nu a fost găsit.", + "unknown_entities.unknown_forum_tag": "Eticheta nu a fost găsită.", "unknown_entities.unknown_guild": "Comunitatea nu a fost găsită.", "unknown_entities.unknown_invite": "Invitația nu a fost găsită sau nu mai este validă.", "unknown_entities.unknown_member": "Membrul nu a fost găsit în această comunitate.", "unknown_entities.unknown_message": "Mesajul nu a fost găsit.", "unknown_entities.unknown_role": "Rolul nu a fost găsit.", + "unknown_entities.unknown_thread_member": "Membrul firului nu a fost găsit.", "unknown_entities.unknown_user": "Utilizatorul nu a fost găsit.", "unknown_entities.unknown_user_flag": "Indicatorul de utilizator specificat nu este recunoscut.", "urls.invalid_url_format": "Formatul URL-ului este invalid.", @@ -491,8 +507,12 @@ const ERROR_I18N_RO_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Codul URL personalizat trebuie să aibă între {min} și {max} caractere.", "vanity_urls.vanity_url_invalid_characters": "URL-ul personalizat poate conține doar litere mici (a-z), cifre (0-9) și liniuțe (-).", "webhooks.cannot_modify_system_webhook": "Nu poți edita mesajele de sistem.", + "webhooks.forum_target_conflict": "Nu poți specifica atât `thread_id`, cât și `thread_name`.", + "webhooks.forum_target_required": "Webhookurile care postează în acest canal au nevoie de `thread_id` sau `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Ai atins numărul maxim de {count, plural, one {# webhook} few {# webhook-uri} other {# de webhook-uri}} pe canal.", "webhooks.max_webhooks_per_guild_reached": "Ai atins numărul maxim de {count, plural, one {# webhook} few {# webhook-uri} other {# de webhook-uri}} pe comunitate.", + "webhooks.service_forum_unsupported": "Acest serviciu de webhook nu poate posta într-un canal de forum sau media fără `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` și `applied_tags` funcționează doar în canalele de forum și media.", "webhooks.unknown_webhook": "Webhook necunoscut.", "webhooks.webhook_name_length_invalid": "Numele webhook-ului trebuie să aibă între {min} și {max} caractere." }); diff --git a/packages/errors/src/i18n/locales/ru.ts b/packages/errors/src/i18n/locales/ru.ts index 4eb4ecd10..68ecebd6f 100644 --- a/packages/errors/src/i18n/locales/ru.ts +++ b/packages/errors/src/i18n/locales/ru.ts @@ -449,16 +449,32 @@ const ERROR_I18N_RU_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Обработка вебхуков недоступна.", "stripe.webhook_signature_invalid": "Недействительная подпись вебхука Stripe.", "stripe.webhook_signature_missing": "Отсутствует подпись вебхука Stripe.", + "threads.channel_has_threads": "Каналы с ветками нельзя преобразовать в каналы объявлений.", + "threads.forum_tag_names_must_be_unique": "Названия тегов должны быть уникальными.", + "threads.forum_tag_required": "Чтобы создать публикацию в этом канале, нужен тег.", + "threads.hide_media_download_option_media_only": "Скрыть параметры скачивания медиафайлов можно только в медиаканалах.", + "threads.invalid_thread_notification_settings": "Недопустимые настройки уведомлений ветки.", + "threads.max_active_threads_reached": "В этом сообществе достигнут лимит активных веток: {count}.", + "threads.max_forum_tags_reached": "В этом канале достигнут лимит тегов: {count}.", + "threads.max_pinned_threads_reached": "В этом канале достигнут лимит закреплённых публикаций: {count}.", + "threads.max_thread_members_reached": "В этой ветке достигнут лимит участников: {count}.", + "threads.no_tags_available_to_non_moderators": "Нужен тег, но в этом канале добавлять теги могут только модераторы.", + "threads.search_index_not_ready": "Поисковый индекс ещё не готов. Попробуй снова через минуту.", + "threads.thread_already_created_for_message": "Для этого сообщения уже создана ветка.", + "threads.thread_archived": "Эта ветка в архиве.", + "threads.thread_locked": "Эта ветка заблокирована.", "tickets.ticket_already_completed": "Этот тикет уже завершён.", "tos_and_age.date_of_birth_format_invalid": "Неверный формат даты рождения.", "tos_and_age.must_agree_to_tos_and_privacy": "Необходимо согласиться с Условиями использования и Политикой конфиденциальности.", "tos_and_age.must_be_minimum_age": "Для создания аккаунта тебе должно быть не меньше {minAge} {minAge, plural, one {года} few {лет} many {лет} other {лет}}.", "unknown_entities.unknown_channel": "Канал не найден.", + "unknown_entities.unknown_forum_tag": "Тег не найден.", "unknown_entities.unknown_guild": "Сообщество не найдено.", "unknown_entities.unknown_invite": "Приглашение не найдено или больше не действует.", "unknown_entities.unknown_member": "Участник не найден в этом сообществе.", "unknown_entities.unknown_message": "Сообщение не найдено.", "unknown_entities.unknown_role": "Роль не найдена.", + "unknown_entities.unknown_thread_member": "Участник ветки не найден.", "unknown_entities.unknown_user": "Пользователь не найден.", "unknown_entities.unknown_user_flag": "Указанный флаг пользователя не распознан.", "urls.invalid_url_format": "Неверный формат URL.", @@ -491,8 +507,12 @@ const ERROR_I18N_RU_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Код персонального URL должен содержать от {min} до {max} символов.", "vanity_urls.vanity_url_invalid_characters": "Персональный URL может содержать только строчные буквы (a-z), цифры (0-9) и дефисы (-).", "webhooks.cannot_modify_system_webhook": "Нельзя редактировать системные сообщения.", + "webhooks.forum_target_conflict": "Нельзя указать одновременно `thread_id` и `thread_name`.", + "webhooks.forum_target_required": "Вебхукам, которые публикуют в этот канал, нужен `thread_id` или `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Ты достиг максимума: {count} {count, plural, one {вебхук} few {вебхука} many {вебхуков} other {вебхука}} на канал.", "webhooks.max_webhooks_per_guild_reached": "Ты достиг максимума: {count} {count, plural, one {вебхук} few {вебхука} many {вебхуков} other {вебхука}} на сообщество.", + "webhooks.service_forum_unsupported": "Этот сервис вебхуков не может публиковать в канал форума или медиаканал без `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` и `applied_tags` работают только в каналах форума и медиаканалах.", "webhooks.unknown_webhook": "Неизвестный вебхук.", "webhooks.webhook_name_length_invalid": "Имя вебхука должно содержать от {min} до {max} символов." }); diff --git a/packages/errors/src/i18n/locales/sv-SE.ts b/packages/errors/src/i18n/locales/sv-SE.ts index e1fbbbf27..ba9ed79b5 100644 --- a/packages/errors/src/i18n/locales/sv-SE.ts +++ b/packages/errors/src/i18n/locales/sv-SE.ts @@ -449,16 +449,32 @@ const ERROR_I18N_SV_SE_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Webhook-bearbetning är otillgänglig.", "stripe.webhook_signature_invalid": "Signaturen för webhooken från Stripe är ogiltig.", "stripe.webhook_signature_missing": "Signaturen för webhooken från Stripe saknas.", + "threads.channel_has_threads": "Kanaler med trådar kan inte göras om till meddelandekanaler.", + "threads.forum_tag_names_must_be_unique": "Taggnamn måste vara unika.", + "threads.forum_tag_required": "Det krävs en tagg för att skapa ett inlägg i den här kanalen.", + "threads.hide_media_download_option_media_only": "Att dölja nedladdningsalternativ för media går bara i mediekanaler.", + "threads.invalid_thread_notification_settings": "Ogiltiga aviseringsinställningar för tråden.", + "threads.max_active_threads_reached": "Den här communityn har nått sin gräns för aktiva trådar: {count}.", + "threads.max_forum_tags_reached": "Den här kanalen har nått sin gräns för taggar: {count}.", + "threads.max_pinned_threads_reached": "Den här kanalen har nått sin gräns för fästa inlägg: {count}.", + "threads.max_thread_members_reached": "Den här tråden har nått sin gräns för medlemmar: {count}.", + "threads.no_tags_available_to_non_moderators": "Det krävs en tagg, men i den här kanalen kan bara moderatorer sätta taggar.", + "threads.search_index_not_ready": "Sökindexet är inte klart än. Försök igen om en liten stund.", + "threads.thread_already_created_for_message": "Det har redan skapats en tråd för det här meddelandet.", + "threads.thread_archived": "Den här tråden är arkiverad.", + "threads.thread_locked": "Den här tråden är låst.", "tickets.ticket_already_completed": "Detta ärende är redan slutfört.", "tos_and_age.date_of_birth_format_invalid": "Ogiltigt format för födelsedatum.", "tos_and_age.must_agree_to_tos_and_privacy": "Du måste godkänna användarvillkoren och integritetspolicyn.", "tos_and_age.must_be_minimum_age": "Du måste vara minst {minAge, number} år gammal för att skapa ett konto.", "unknown_entities.unknown_channel": "Kanalen hittades inte.", + "unknown_entities.unknown_forum_tag": "Taggen hittades inte.", "unknown_entities.unknown_guild": "Communityn hittades inte.", "unknown_entities.unknown_invite": "Inbjudan hittades inte eller är inte längre giltig.", "unknown_entities.unknown_member": "Medlemmen hittades inte i den här communityn.", "unknown_entities.unknown_message": "Meddelandet hittades inte.", "unknown_entities.unknown_role": "Rollen hittades inte.", + "unknown_entities.unknown_thread_member": "Trådmedlemmen hittades inte.", "unknown_entities.unknown_user": "Användaren hittades inte.", "unknown_entities.unknown_user_flag": "Den angivna användarflaggan känns inte igen.", "urls.invalid_url_format": "Ogiltigt URL-format.", @@ -491,8 +507,12 @@ const ERROR_I18N_SV_SE_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Koden för den anpassade URL:en måste vara mellan {min} och {max} tecken lång.", "vanity_urls.vanity_url_invalid_characters": "En anpassad URL får endast innehålla gemener (a-z), siffror (0-9) och bindestreck (-).", "webhooks.cannot_modify_system_webhook": "Du kan inte ändra systemmeddelanden.", + "webhooks.forum_target_conflict": "Du kan inte ange både `thread_id` och `thread_name`.", + "webhooks.forum_target_required": "Webhooks som postar i den här kanalen behöver ett `thread_id` eller ett `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Du har nått gränsen på {count, plural, one {# webhook} other {# webhooks}} per kanal.", "webhooks.max_webhooks_per_guild_reached": "Du har nått gränsen på {count, plural, one {# webhook} other {# webhooks}} per community.", + "webhooks.service_forum_unsupported": "Den här webhooktjänsten kan inte posta i en forum- eller mediekanal utan ett `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` och `applied_tags` fungerar bara i forum- och mediekanaler.", "webhooks.unknown_webhook": "Okänd webhook.", "webhooks.webhook_name_length_invalid": "Webhooknamnet måste vara mellan {min} och {max} tecken långt." }); diff --git a/packages/errors/src/i18n/locales/th.ts b/packages/errors/src/i18n/locales/th.ts index 436843fd5..1f4a50887 100644 --- a/packages/errors/src/i18n/locales/th.ts +++ b/packages/errors/src/i18n/locales/th.ts @@ -449,16 +449,32 @@ const ERROR_I18N_TH_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "การประมวลผล webhook ไม่พร้อมใช้งาน", "stripe.webhook_signature_invalid": "ลายเซ็น webhook Stripe ไม่ถูกต้อง", "stripe.webhook_signature_missing": "ไม่พบลายเซ็น webhook Stripe", + "threads.channel_has_threads": "ไม่สามารถแปลงช่องที่มีเธรดเป็นช่องประกาศได้", + "threads.forum_tag_names_must_be_unique": "ชื่อแท็กต้องไม่ซ้ำกัน", + "threads.forum_tag_required": "ต้องมีแท็กจึงจะสร้างโพสต์ในช่องนี้ได้", + "threads.hide_media_download_option_media_only": "การซ่อนตัวเลือกดาวน์โหลดสื่อใช้ได้เฉพาะในช่องสื่อ", + "threads.invalid_thread_notification_settings": "การตั้งค่าการแจ้งเตือนของเธรดไม่ถูกต้อง", + "threads.max_active_threads_reached": "คอมมูนิตี้นี้มีเธรดที่ใช้งานอยู่ครบขีดจำกัดแล้ว: {count}", + "threads.max_forum_tags_reached": "ช่องนี้มีแท็กครบขีดจำกัดแล้ว: {count}", + "threads.max_pinned_threads_reached": "ช่องนี้มีโพสต์ที่ปักหมุดครบขีดจำกัดแล้ว: {count}", + "threads.max_thread_members_reached": "เธรดนี้มีสมาชิกครบขีดจำกัดแล้ว: {count}", + "threads.no_tags_available_to_non_moderators": "ต้องมีแท็ก แต่ในช่องนี้มีเพียงผู้ดูแลเท่านั้นที่ใส่แท็กได้", + "threads.search_index_not_ready": "ดัชนีการค้นหายังไม่พร้อม ลองใหม่อีกครั้งในอีกสักครู่", + "threads.thread_already_created_for_message": "มีการสร้างเธรดสำหรับข้อความนี้แล้ว", + "threads.thread_archived": "เธรดนี้ถูกเก็บถาวรแล้ว", + "threads.thread_locked": "เธรดนี้ถูกล็อกอยู่", "tickets.ticket_already_completed": "ตั๋วนี้ดำเนินการเสร็จสิ้นแล้ว", "tos_and_age.date_of_birth_format_invalid": "รูปแบบวันเกิดไม่ถูกต้อง", "tos_and_age.must_agree_to_tos_and_privacy": "คุณต้องยอมรับข้อกำหนดในการให้บริการ และนโยบายความเป็นส่วนตัว", "tos_and_age.must_be_minimum_age": "คุณต้องมีอายุอย่างน้อย {minAge} {minAge, plural, other {ปี}} จึงจะสร้างบัญชีได้", "unknown_entities.unknown_channel": "ไม่พบช่อง", + "unknown_entities.unknown_forum_tag": "ไม่พบแท็ก", "unknown_entities.unknown_guild": "ไม่พบคอมมูนิตี้", "unknown_entities.unknown_invite": "ไม่พบคำเชิญ หรือคำเชิญใช้งานไม่ได้แล้ว", "unknown_entities.unknown_member": "ไม่พบสมาชิกในคอมมูนิตี้นี้", "unknown_entities.unknown_message": "ไม่พบข้อความ", "unknown_entities.unknown_role": "ไม่พบบทบาท", + "unknown_entities.unknown_thread_member": "ไม่พบสมาชิกของเธรด", "unknown_entities.unknown_user": "ไม่พบผู้ใช้", "unknown_entities.unknown_user_flag": "ไม่รู้จักแฟล็กผู้ใช้ที่ระบุ", "urls.invalid_url_format": "รูปแบบ URL ไม่ถูกต้อง", @@ -491,8 +507,12 @@ const ERROR_I18N_TH_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "รหัส Vanity URL ต้องมีความยาวระหว่าง {min} ถึง {max} ตัวอักษร", "vanity_urls.vanity_url_invalid_characters": "Vanity URL สามารถมีได้เฉพาะตัวอักษรพิมพ์เล็ก (a-z), ตัวเลข (0-9) และเครื่องหมายขีดกลาง (-)", "webhooks.cannot_modify_system_webhook": "คุณไม่สามารถแก้ไขข้อความระบบได้", + "webhooks.forum_target_conflict": "คุณระบุ `thread_id` และ `thread_name` พร้อมกันไม่ได้", + "webhooks.forum_target_required": "webhook ที่โพสต์ในช่องนี้ต้องมี `thread_id` หรือ `thread_name`", "webhooks.max_webhooks_per_channel_reached": "คุณมี webhook ครบ {count} {count, plural, other {รายการ}} ต่อช่องแล้ว", "webhooks.max_webhooks_per_guild_reached": "คุณมี webhook ครบ {count} {count, plural, other {รายการ}} ต่อคอมมูนิตี้แล้ว", + "webhooks.service_forum_unsupported": "บริการ webhook นี้โพสต์ในช่องฟอรัมหรือช่องสื่อโดยไม่มี `thread_id` ไม่ได้", + "webhooks.thread_name_requires_forum": "`thread_name` และ `applied_tags` ใช้ได้เฉพาะในช่องฟอรัมและช่องสื่อ", "webhooks.unknown_webhook": "ไม่พบ webhook", "webhooks.webhook_name_length_invalid": "ชื่อ webhook ต้องมีความยาวระหว่าง {min} ถึง {max} ตัวอักษร" }); diff --git a/packages/errors/src/i18n/locales/tr.ts b/packages/errors/src/i18n/locales/tr.ts index b7c6c769a..2a0b43432 100644 --- a/packages/errors/src/i18n/locales/tr.ts +++ b/packages/errors/src/i18n/locales/tr.ts @@ -449,16 +449,32 @@ const ERROR_I18N_TR_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Webhook işleme kullanılamıyor.", "stripe.webhook_signature_invalid": "Stripe webhook imzası geçersiz.", "stripe.webhook_signature_missing": "Stripe webhook imzası eksik.", + "threads.channel_has_threads": "Alt başlık içeren kanallar duyuru kanalına dönüştürülemez.", + "threads.forum_tag_names_must_be_unique": "Etiket adları benzersiz olmalıdır.", + "threads.forum_tag_required": "Bu kanalda gönderi oluşturmak için bir etiket gerekir.", + "threads.hide_media_download_option_media_only": "Medya indirme seçeneklerini gizleme yalnızca medya kanallarında kullanılabilir.", + "threads.invalid_thread_notification_settings": "Geçersiz alt başlık bildirim ayarları.", + "threads.max_active_threads_reached": "Bu topluluk aktif alt başlık sınırına ulaştı: {count}.", + "threads.max_forum_tags_reached": "Bu kanal etiket sınırına ulaştı: {count}.", + "threads.max_pinned_threads_reached": "Bu kanal sabitlenmiş gönderi sınırına ulaştı: {count}.", + "threads.max_thread_members_reached": "Bu alt başlık üye sınırına ulaştı: {count}.", + "threads.no_tags_available_to_non_moderators": "Etiket gerekli, ancak bu kanalda etiketleri yalnızca moderatörler ekleyebilir.", + "threads.search_index_not_ready": "Arama dizini henüz hazır değil. Birazdan tekrar dene.", + "threads.thread_already_created_for_message": "Bu mesaj için zaten bir alt başlık oluşturuldu.", + "threads.thread_archived": "Bu alt başlık arşivlendi.", + "threads.thread_locked": "Bu alt başlık kilitli.", "tickets.ticket_already_completed": "Bu bilet zaten tamamlandı.", "tos_and_age.date_of_birth_format_invalid": "Geçersiz doğum tarihi biçimi.", "tos_and_age.must_agree_to_tos_and_privacy": "Hizmet Şartlarını ve Gizlilik Politikasını kabul etmelisin.", "tos_and_age.must_be_minimum_age": "Hesap oluşturmak için en az {minAge, plural, one {# yaşında} other {# yaşında}} olmalısın.", "unknown_entities.unknown_channel": "Kanal bulunamadı.", + "unknown_entities.unknown_forum_tag": "Etiket bulunamadı.", "unknown_entities.unknown_guild": "Topluluk bulunamadı.", "unknown_entities.unknown_invite": "Davet bulunamadı veya artık geçerli değil.", "unknown_entities.unknown_member": "Bu toplulukta üye bulunamadı.", "unknown_entities.unknown_message": "Mesaj bulunamadı.", "unknown_entities.unknown_role": "Rol bulunamadı.", + "unknown_entities.unknown_thread_member": "Alt başlık üyesi bulunamadı.", "unknown_entities.unknown_user": "Kullanıcı bulunamadı.", "unknown_entities.unknown_user_flag": "Belirtilen kullanıcı bayrağı tanınmıyor.", "urls.invalid_url_format": "Geçersiz URL biçimi.", @@ -491,8 +507,12 @@ const ERROR_I18N_TR_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Özel URL kodu {min} ile {max} karakter arasında olmalı.", "vanity_urls.vanity_url_invalid_characters": "Özel URL yalnızca küçük harfler (a-z), rakamlar (0-9) ve kısa çizgiler (-) içerebilir.", "webhooks.cannot_modify_system_webhook": "Sistem mesajlarını düzenleyemezsin.", + "webhooks.forum_target_conflict": "`thread_id` ve `thread_name` alanlarını birlikte belirtemezsin.", + "webhooks.forum_target_required": "Bu kanala gönderi yapan webhook'lar için `thread_id` veya `thread_name` gerekir.", "webhooks.max_webhooks_per_channel_reached": "Kanal başına en fazla {count, plural, one {# webhook} other {# webhook}} sınırına ulaştın.", "webhooks.max_webhooks_per_guild_reached": "Topluluk başına en fazla {count, plural, one {# webhook} other {# webhook}} sınırına ulaştın.", + "webhooks.service_forum_unsupported": "Bu webhook hizmeti `thread_id` olmadan forum veya medya kanalına gönderi yapamaz.", + "webhooks.thread_name_requires_forum": "`thread_name` ve `applied_tags` yalnızca forum ve medya kanallarında çalışır.", "webhooks.unknown_webhook": "Bilinmeyen webhook.", "webhooks.webhook_name_length_invalid": "Webhook adı {min} ile {max} karakter arasında olmalı." }); diff --git a/packages/errors/src/i18n/locales/uk.ts b/packages/errors/src/i18n/locales/uk.ts index 5a7ce7f81..1dbd56960 100644 --- a/packages/errors/src/i18n/locales/uk.ts +++ b/packages/errors/src/i18n/locales/uk.ts @@ -449,16 +449,32 @@ const ERROR_I18N_UK_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Обробка вебхуків недоступна.", "stripe.webhook_signature_invalid": "Недійсний підпис вебхука Stripe.", "stripe.webhook_signature_missing": "Відсутній підпис вебхука Stripe.", + "threads.channel_has_threads": "Канали з гілками не можна перетворити на канали оголошень.", + "threads.forum_tag_names_must_be_unique": "Назви тегів мають бути унікальними.", + "threads.forum_tag_required": "Щоб створити допис у цьому каналі, потрібен тег.", + "threads.hide_media_download_option_media_only": "Приховати параметри завантаження медіафайлів можна лише в медіаканалах.", + "threads.invalid_thread_notification_settings": "Недійсні налаштування сповіщень гілки.", + "threads.max_active_threads_reached": "У цій спільноті досягнуто ліміту активних гілок: {count}.", + "threads.max_forum_tags_reached": "У цьому каналі досягнуто ліміту тегів: {count}.", + "threads.max_pinned_threads_reached": "У цьому каналі досягнуто ліміту закріплених дописів: {count}.", + "threads.max_thread_members_reached": "У цій гілці досягнуто ліміту учасників: {count}.", + "threads.no_tags_available_to_non_moderators": "Потрібен тег, але в цьому каналі додавати теги можуть лише модератори.", + "threads.search_index_not_ready": "Пошуковий індекс ще не готовий. Спробуй ще раз за мить.", + "threads.thread_already_created_for_message": "Для цього повідомлення вже створено гілку.", + "threads.thread_archived": "Ця гілка в архіві.", + "threads.thread_locked": "Ця гілка заблокована.", "tickets.ticket_already_completed": "Цей тікет уже завершено.", "tos_and_age.date_of_birth_format_invalid": "Недійсний формат дати народження.", "tos_and_age.must_agree_to_tos_and_privacy": "Ти маєш погодитися з Умовами використання і Політикою конфіденційності.", "tos_and_age.must_be_minimum_age": "Тобі має бути щонайменше {minAge} {minAge, plural, one {рік} few {роки} many {років} other {року}}, щоб створити акаунт.", "unknown_entities.unknown_channel": "Канал не знайдено.", + "unknown_entities.unknown_forum_tag": "Тег не знайдено.", "unknown_entities.unknown_guild": "Спільноту не знайдено.", "unknown_entities.unknown_invite": "Запрошення не знайдено або воно вже недійсне.", "unknown_entities.unknown_member": "Учасника не знайдено в цій спільноті.", "unknown_entities.unknown_message": "Повідомлення не знайдено.", "unknown_entities.unknown_role": "Роль не знайдено.", + "unknown_entities.unknown_thread_member": "Учасника гілки не знайдено.", "unknown_entities.unknown_user": "Користувача не знайдено.", "unknown_entities.unknown_user_flag": "Зазначений прапорець користувача не розпізнано.", "urls.invalid_url_format": "Неправильний формат URL.", @@ -491,8 +507,12 @@ const ERROR_I18N_UK_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Код персоналізованої URL-адреси має містити від {min} до {max} символів.", "vanity_urls.vanity_url_invalid_characters": "Персоналізована URL-адреса може містити лише малі літери (a-z), цифри (0-9) та дефіси (-).", "webhooks.cannot_modify_system_webhook": "Системні повідомлення не можна редагувати.", + "webhooks.forum_target_conflict": "Не можна вказати водночас `thread_id` і `thread_name`.", + "webhooks.forum_target_required": "Вебхукам, які публікують у цей канал, потрібен `thread_id` або `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Досягнуто максимуму: {count} {count, plural, one {вебхук} few {вебхуки} many {вебхуків} other {вебхука}} на канал.", "webhooks.max_webhooks_per_guild_reached": "Досягнуто максимуму: {count} {count, plural, one {вебхук} few {вебхуки} many {вебхуків} other {вебхука}} на спільноту.", + "webhooks.service_forum_unsupported": "Цей сервіс вебхуків не може публікувати в канал форуму чи медіаканал без `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` і `applied_tags` працюють лише в каналах форуму та медіаканалах.", "webhooks.unknown_webhook": "Невідомий вебхук.", "webhooks.webhook_name_length_invalid": "Назва вебхука має містити від {min} до {max} символів." }); diff --git a/packages/errors/src/i18n/locales/vi.ts b/packages/errors/src/i18n/locales/vi.ts index 5e907b4df..79abb5d7e 100644 --- a/packages/errors/src/i18n/locales/vi.ts +++ b/packages/errors/src/i18n/locales/vi.ts @@ -449,16 +449,32 @@ const ERROR_I18N_VI_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Xử lý webhook không khả dụng.", "stripe.webhook_signature_invalid": "Chữ ký số webhook Stripe không hợp lệ.", "stripe.webhook_signature_missing": "Thiếu chữ ký số webhook Stripe.", + "threads.channel_has_threads": "Không thể chuyển kênh có chủ đề thành kênh thông báo.", + "threads.forum_tag_names_must_be_unique": "Tên thẻ phải là duy nhất.", + "threads.forum_tag_required": "Cần có thẻ để tạo bài đăng trong kênh này.", + "threads.hide_media_download_option_media_only": "Chỉ có thể ẩn tùy chọn tải xuống phương tiện trong kênh phương tiện.", + "threads.invalid_thread_notification_settings": "Cài đặt thông báo của chủ đề không hợp lệ.", + "threads.max_active_threads_reached": "Cộng đồng này đã đạt giới hạn chủ đề đang hoạt động: {count}.", + "threads.max_forum_tags_reached": "Kênh này đã đạt giới hạn thẻ: {count}.", + "threads.max_pinned_threads_reached": "Kênh này đã đạt giới hạn bài đăng được ghim: {count}.", + "threads.max_thread_members_reached": "Chủ đề này đã đạt giới hạn thành viên: {count}.", + "threads.no_tags_available_to_non_moderators": "Cần có thẻ, nhưng trong kênh này chỉ người kiểm duyệt mới có thể gắn thẻ.", + "threads.search_index_not_ready": "Chỉ mục tìm kiếm chưa sẵn sàng. Hãy thử lại sau giây lát.", + "threads.thread_already_created_for_message": "Đã có chủ đề được tạo cho tin nhắn này.", + "threads.thread_archived": "Chủ đề này đã được lưu trữ.", + "threads.thread_locked": "Chủ đề này đã bị khóa.", "tickets.ticket_already_completed": "Vé này đã được hoàn tất.", "tos_and_age.date_of_birth_format_invalid": "Định dạng ngày sinh không hợp lệ.", "tos_and_age.must_agree_to_tos_and_privacy": "Bạn phải đồng ý với Điều khoản dịch vụ và Chính sách bảo mật.", "tos_and_age.must_be_minimum_age": "Bạn phải từ {minAge} {minAge, plural, other {tuổi}} trở lên để tạo tài khoản.", "unknown_entities.unknown_channel": "Không tìm thấy kênh này.", + "unknown_entities.unknown_forum_tag": "Không tìm thấy thẻ.", "unknown_entities.unknown_guild": "Không tìm thấy cộng đồng này.", "unknown_entities.unknown_invite": "Không tìm thấy lời mời này hoặc lời mời không còn hiệu lực.", "unknown_entities.unknown_member": "Không tìm thấy thành viên này trong cộng đồng.", "unknown_entities.unknown_message": "Không tìm thấy tin nhắn này.", "unknown_entities.unknown_role": "Không tìm thấy vai trò này.", + "unknown_entities.unknown_thread_member": "Không tìm thấy thành viên của chủ đề.", "unknown_entities.unknown_user": "Không tìm thấy người dùng này.", "unknown_entities.unknown_user_flag": "Không nhận dạng được flag người dùng đã chỉ định.", "urls.invalid_url_format": "Định dạng URL không hợp lệ.", @@ -491,8 +507,12 @@ const ERROR_I18N_VI_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "Mã URL tùy chỉnh phải có độ dài từ {min} đến {max} ký tự.", "vanity_urls.vanity_url_invalid_characters": "URL tùy chỉnh chỉ có thể chứa chữ cái thường (a-z), chữ số (0-9) và dấu gạch nối (-).", "webhooks.cannot_modify_system_webhook": "Bạn không thể chỉnh sửa tin nhắn hệ thống.", + "webhooks.forum_target_conflict": "Bạn không thể chỉ định cả `thread_id` và `thread_name`.", + "webhooks.forum_target_required": "Webhook đăng vào kênh này cần có `thread_id` hoặc `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Bạn đã đạt tối đa {count} {count, plural, other {webhook}} cho mỗi kênh.", "webhooks.max_webhooks_per_guild_reached": "Bạn đã đạt tối đa {count} {count, plural, other {webhook}} cho mỗi cộng đồng.", + "webhooks.service_forum_unsupported": "Dịch vụ webhook này không thể đăng vào kênh diễn đàn hoặc kênh phương tiện khi không có `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` và `applied_tags` chỉ hoạt động trong kênh diễn đàn và kênh phương tiện.", "webhooks.unknown_webhook": "Webhook không xác định.", "webhooks.webhook_name_length_invalid": "Tên webhook phải có độ dài từ {min} đến {max} ký tự." }); diff --git a/packages/errors/src/i18n/locales/zh-CN.ts b/packages/errors/src/i18n/locales/zh-CN.ts index 75f6f3ed2..c096fe35a 100644 --- a/packages/errors/src/i18n/locales/zh-CN.ts +++ b/packages/errors/src/i18n/locales/zh-CN.ts @@ -449,16 +449,32 @@ const ERROR_I18N_ZH_CN_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Webhook 处理不可用。", "stripe.webhook_signature_invalid": "Stripe webhook 签名无效。", "stripe.webhook_signature_missing": "缺少 Stripe webhook 签名。", + "threads.channel_has_threads": "含有子区的频道无法转换为公告频道。", + "threads.forum_tag_names_must_be_unique": "标签名称不能重复。", + "threads.forum_tag_required": "在此频道发帖需要选择一个标签。", + "threads.hide_media_download_option_media_only": "只有媒体频道才能隐藏媒体下载选项。", + "threads.invalid_thread_notification_settings": "子区通知设置无效。", + "threads.max_active_threads_reached": "此社区的活跃子区已达上限:{count}。", + "threads.max_forum_tags_reached": "此频道的标签已达上限:{count}。", + "threads.max_pinned_threads_reached": "此频道的置顶帖子已达上限:{count}。", + "threads.max_thread_members_reached": "此子区的成员已达上限:{count}。", + "threads.no_tags_available_to_non_moderators": "需要选择标签,但在此频道中只有版主可以添加标签。", + "threads.search_index_not_ready": "搜索索引尚未就绪,请稍后再试。", + "threads.thread_already_created_for_message": "已为此消息创建过子区。", + "threads.thread_archived": "此子区已归档。", + "threads.thread_locked": "此子区已锁定。", "tickets.ticket_already_completed": "此工单已完成。", "tos_and_age.date_of_birth_format_invalid": "出生日期格式无效。", "tos_and_age.must_agree_to_tos_and_privacy": "你必须同意服务条款和隐私政策。", "tos_and_age.must_be_minimum_age": "你必须年满 {minAge, plural, other {# 岁}}才能注册账号。", "unknown_entities.unknown_channel": "未找到频道。", + "unknown_entities.unknown_forum_tag": "未找到标签。", "unknown_entities.unknown_guild": "未找到社区。", "unknown_entities.unknown_invite": "未找到邀请或邀请已失效。", "unknown_entities.unknown_member": "未在此社区中找到成员。", "unknown_entities.unknown_message": "未找到消息。", "unknown_entities.unknown_role": "未找到身份组。", + "unknown_entities.unknown_thread_member": "未找到子区成员。", "unknown_entities.unknown_user": "未找到用户。", "unknown_entities.unknown_user_flag": "无法识别指定的用户标记。", "urls.invalid_url_format": "URL 格式无效。", @@ -491,8 +507,12 @@ const ERROR_I18N_ZH_CN_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "自定义 URL 代码长度必须在 {min} 到 {max} 个字符之间。", "vanity_urls.vanity_url_invalid_characters": "自定义 URL 只能包含小写字母(a-z)、数字(0-9)和连字符(-)。", "webhooks.cannot_modify_system_webhook": "你无法编辑系统消息。", + "webhooks.forum_target_conflict": "不能同时指定 `thread_id` 和 `thread_name`。", + "webhooks.forum_target_required": "在此频道发帖的 webhook 需要提供 `thread_id` 或 `thread_name`。", "webhooks.max_webhooks_per_channel_reached": "你已达到每个频道 {count, plural, other {# 个 webhook}}的上限。", "webhooks.max_webhooks_per_guild_reached": "你已达到每个社区 {count, plural, other {# 个 webhook}}的上限。", + "webhooks.service_forum_unsupported": "此 webhook 服务在没有 `thread_id` 的情况下无法在论坛频道或媒体频道中发帖。", + "webhooks.thread_name_requires_forum": "`thread_name` 和 `applied_tags` 仅适用于论坛频道和媒体频道。", "webhooks.unknown_webhook": "未知 webhook。", "webhooks.webhook_name_length_invalid": "Webhook 名称长度必须在 {min} 到 {max} 个字符之间。" }); diff --git a/packages/errors/src/i18n/locales/zh-TW.ts b/packages/errors/src/i18n/locales/zh-TW.ts index 2ba1dba34..e22bc24ba 100644 --- a/packages/errors/src/i18n/locales/zh-TW.ts +++ b/packages/errors/src/i18n/locales/zh-TW.ts @@ -449,16 +449,32 @@ const ERROR_I18N_ZH_TW_MESSAGES = defineErrorI18nLocaleMessages({ "stripe.webhook_not_available": "Webhook 處理功能無法使用。", "stripe.webhook_signature_invalid": "Stripe Webhook 簽名無效。", "stripe.webhook_signature_missing": "缺少 Stripe Webhook 簽名。", + "threads.channel_has_threads": "含有討論串的頻道無法轉換為公告頻道。", + "threads.forum_tag_names_must_be_unique": "標籤名稱不能重複。", + "threads.forum_tag_required": "在此頻道發文需要選擇一個標籤。", + "threads.hide_media_download_option_media_only": "只有媒體頻道才能隱藏媒體下載選項。", + "threads.invalid_thread_notification_settings": "討論串通知設定無效。", + "threads.max_active_threads_reached": "此社群的活躍討論串已達上限:{count}。", + "threads.max_forum_tags_reached": "此頻道的標籤已達上限:{count}。", + "threads.max_pinned_threads_reached": "此頻道的釘選貼文已達上限:{count}。", + "threads.max_thread_members_reached": "此討論串的成員已達上限:{count}。", + "threads.no_tags_available_to_non_moderators": "需要選擇標籤,但在此頻道中只有管理員可以新增標籤。", + "threads.search_index_not_ready": "搜尋索引尚未就緒,請稍後再試。", + "threads.thread_already_created_for_message": "已為此訊息建立過討論串。", + "threads.thread_archived": "此討論串已封存。", + "threads.thread_locked": "此討論串已鎖定。", "tickets.ticket_already_completed": "此票證已完成。", "tos_and_age.date_of_birth_format_invalid": "出生日期格式無效。", "tos_and_age.must_agree_to_tos_and_privacy": "你必須同意服務條款與隱私權政策。", "tos_and_age.must_be_minimum_age": "你必須至少 {minAge, plural, other {# 歲}}才能建立帳號。", "unknown_entities.unknown_channel": "找不到頻道。", + "unknown_entities.unknown_forum_tag": "找不到標籤。", "unknown_entities.unknown_guild": "找不到社群。", "unknown_entities.unknown_invite": "找不到邀請,或邀請已失效。", "unknown_entities.unknown_member": "在此社群中找不到成員。", "unknown_entities.unknown_message": "找不到訊息。", "unknown_entities.unknown_role": "找不到身分組。", + "unknown_entities.unknown_thread_member": "找不到討論串成員。", "unknown_entities.unknown_user": "找不到使用者。", "unknown_entities.unknown_user_flag": "無法辨識指定的使用者標誌。", "urls.invalid_url_format": "無效的 URL 格式。", @@ -491,8 +507,12 @@ const ERROR_I18N_ZH_TW_MESSAGES = defineErrorI18nLocaleMessages({ "vanity_urls.vanity_url_code_length_invalid": "自訂網址代碼必須介於 {min} 到 {max} 個字元之間。", "vanity_urls.vanity_url_invalid_characters": "自訂網址只能包含小寫字母 (a-z)、數字 (0-9) 和連字號 (-)。", "webhooks.cannot_modify_system_webhook": "你無法編輯系統訊息。", + "webhooks.forum_target_conflict": "無法同時指定 `thread_id` 和 `thread_name`。", + "webhooks.forum_target_required": "在此頻道發文的 Webhook 需要提供 `thread_id` 或 `thread_name`。", "webhooks.max_webhooks_per_channel_reached": "你已達到每個頻道最多 {count, plural, other {# 個}} Webhook 的上限。", "webhooks.max_webhooks_per_guild_reached": "你已達到每個社群最多 {count, plural, other {# 個}} Webhook 的上限。", + "webhooks.service_forum_unsupported": "此 Webhook 服務在沒有 `thread_id` 的情況下無法在論壇頻道或媒體頻道中發文。", + "webhooks.thread_name_requires_forum": "`thread_name` 和 `applied_tags` 僅適用於論壇頻道和媒體頻道。", "webhooks.unknown_webhook": "未知的 Webhook。", "webhooks.webhook_name_length_invalid": "Webhook 名稱必須介於 {min} 到 {max} 個字元之間。" }); diff --git a/packages/errors/src/i18n/weblate/locales/ar.json b/packages/errors/src/i18n/weblate/locales/ar.json index 64522c188..220654a06 100644 --- a/packages/errors/src/i18n/weblate/locales/ar.json +++ b/packages/errors/src/i18n/weblate/locales/ar.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "معالجة الويب هوك غير متاحة.", "stripe.webhook_signature_invalid": "توقيع ويب هوك Stripe غير صالح.", "stripe.webhook_signature_missing": "توقيع ويب هوك Stripe مفقود.", + "threads.channel_has_threads": "لا يمكن تحويل القنوات التي تحتوي على سلاسل رسائل إلى قنوات إعلانات.", + "threads.forum_tag_names_must_be_unique": "يجب أن تكون أسماء الوسوم فريدة.", + "threads.forum_tag_required": "يلزم وسم لإنشاء منشور في هذه القناة.", + "threads.hide_media_download_option_media_only": "إخفاء خيارات تنزيل الوسائط متاح في قنوات الوسائط فقط.", + "threads.invalid_thread_notification_settings": "إعدادات إشعارات سلسلة الرسائل غير صالحة.", + "threads.max_active_threads_reached": "وصل هذا المجتمع إلى الحد الأقصى لسلاسل الرسائل النشطة: {count}.", + "threads.max_forum_tags_reached": "وصلت هذه القناة إلى الحد الأقصى للوسوم: {count}.", + "threads.max_pinned_threads_reached": "وصلت هذه القناة إلى الحد الأقصى للمنشورات المثبتة: {count}.", + "threads.max_thread_members_reached": "وصلت سلسلة الرسائل هذه إلى الحد الأقصى للأعضاء: {count}.", + "threads.no_tags_available_to_non_moderators": "يلزم وسم، لكن المشرفين وحدهم يمكنهم إضافة الوسوم في هذه القناة.", + "threads.search_index_not_ready": "فهرس البحث غير جاهز بعد. حاول مرة أخرى بعد قليل.", + "threads.thread_already_created_for_message": "تم إنشاء سلسلة رسائل لهذه الرسالة بالفعل.", + "threads.thread_archived": "سلسلة الرسائل هذه مؤرشفة.", + "threads.thread_locked": "سلسلة الرسائل هذه مقفلة.", "tickets.ticket_already_completed": "تم إكمال هذه التذكرة بالفعل.", "tos_and_age.date_of_birth_format_invalid": "تنسيق تاريخ الميلاد غير صالح.", "tos_and_age.must_agree_to_tos_and_privacy": "يجب أن توافق على شروط الخدمة وسياسة الخصوصية.", "tos_and_age.must_be_minimum_age": "يجب أن يكون عمرك {minAge, plural, zero {# سنة} one {سنة واحدة} two {سنتين} few {# سنوات} many {# سنة} other {# سنة}} على الأقل لإنشاء حساب.", "unknown_entities.unknown_channel": "لم يتم العثور على القناة.", + "unknown_entities.unknown_forum_tag": "لم يتم العثور على الوسم.", "unknown_entities.unknown_guild": "لم يتم العثور على المجتمع.", "unknown_entities.unknown_invite": "لم يتم العثور على الدعوة أو لم تعد صالحة.", "unknown_entities.unknown_member": "لم يتم العثور على العضو في هذا المجتمع.", "unknown_entities.unknown_message": "لم يتم العثور على الرسالة.", "unknown_entities.unknown_role": "لم يتم العثور على الدور.", + "unknown_entities.unknown_thread_member": "لم يتم العثور على عضو سلسلة الرسائل.", "unknown_entities.unknown_user": "لم يتم العثور على المستخدم.", "unknown_entities.unknown_user_flag": "علامة المستخدم المحددة غير معروفة.", "urls.invalid_url_format": "صيغة الرابط غير صالحة.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "يجب أن يكون رمز عنوان URL المخصص بين {min} و{max} حرفًا.", "vanity_urls.vanity_url_invalid_characters": "يمكن أن يحتوي عنوان URL المخصص على أحرف صغيرة (a-z) وأرقام (0-9) وواصلات (-) فقط.", "webhooks.cannot_modify_system_webhook": "لا يمكنك تعديل رسائل النظام.", + "webhooks.forum_target_conflict": "لا يمكنك تحديد `thread_id` و`thread_name` معًا.", + "webhooks.forum_target_required": "تحتاج خطافات الويب التي تنشر في هذه القناة إلى `thread_id` أو `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "لقد وصلت إلى الحد الأقصى وهو {count, plural, zero {# ويب هوك} one {ويب هوك واحد} two {# ويب هوك} few {# ويب هوك} many {# ويب هوك} other {# ويب هوك}} لكل قناة.", "webhooks.max_webhooks_per_guild_reached": "لقد وصلت إلى الحد الأقصى وهو {count, plural, zero {# ويب هوك} one {ويب هوك واحد} two {# ويب هوك} few {# ويب هوك} many {# ويب هوك} other {# ويب هوك}} لكل مجتمع.", + "webhooks.service_forum_unsupported": "لا يمكن لخدمة خطاف الويب هذه النشر في قناة منتدى أو قناة وسائط دون `thread_id`.", + "webhooks.thread_name_requires_forum": "لا يعمل `thread_name` و`applied_tags` إلا في قنوات المنتدى والوسائط.", "webhooks.unknown_webhook": "ويب هوك غير معروف.", "webhooks.webhook_name_length_invalid": "يجب أن يكون اسم الويب هوك بين {min} و{max} حرفًا." } diff --git a/packages/errors/src/i18n/weblate/locales/bg.json b/packages/errors/src/i18n/weblate/locales/bg.json index d8fa17153..dc74ab938 100644 --- a/packages/errors/src/i18n/weblate/locales/bg.json +++ b/packages/errors/src/i18n/weblate/locales/bg.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Обработката на уебхукове не е достъпна.", "stripe.webhook_signature_invalid": "Подписът на уебхука от Stripe е невалиден.", "stripe.webhook_signature_missing": "Липсва подпис на уебхука от Stripe.", + "threads.channel_has_threads": "Канали с нишки не могат да се превръщат в канали за обяви.", + "threads.forum_tag_names_must_be_unique": "Имената на таговете трябва да са уникални.", + "threads.forum_tag_required": "За да създадеш публикация в този канал, е нужен таг.", + "threads.hide_media_download_option_media_only": "Скриването на опциите за изтегляне на медия е достъпно само в медийни канали.", + "threads.invalid_thread_notification_settings": "Невалидни настройки за известия на нишката.", + "threads.max_active_threads_reached": "Тази общност достигна лимита си за активни нишки: {count}.", + "threads.max_forum_tags_reached": "Този канал достигна лимита си за тагове: {count}.", + "threads.max_pinned_threads_reached": "Този канал достигна лимита си за закачени публикации: {count}.", + "threads.max_thread_members_reached": "Тази нишка достигна лимита си за членове: {count}.", + "threads.no_tags_available_to_non_moderators": "Нужен е таг, но в този канал само модераторите могат да добавят тагове.", + "threads.search_index_not_ready": "Индексът за търсене още не е готов. Опитай отново след малко.", + "threads.thread_already_created_for_message": "За това съобщение вече е създадена нишка.", + "threads.thread_archived": "Тази нишка е архивирана.", + "threads.thread_locked": "Тази нишка е заключена.", "tickets.ticket_already_completed": "Този тикет вече е приключен.", "tos_and_age.date_of_birth_format_invalid": "Невалиден формат на дата на раждане.", "tos_and_age.must_agree_to_tos_and_privacy": "Трябва да се съгласиш с Общите условия и Политиката за поверителност.", "tos_and_age.must_be_minimum_age": "Трябва да си поне на {minAge} {minAge, plural, one {година} other {години}}, за да създадеш акаунт.", "unknown_entities.unknown_channel": "Каналът не е намерен.", + "unknown_entities.unknown_forum_tag": "Тагът не е намерен.", "unknown_entities.unknown_guild": "Общността не е намерена.", "unknown_entities.unknown_invite": "Поканата не е намерена или вече не е валидна.", "unknown_entities.unknown_member": "Членът не е намерен в тази общност.", "unknown_entities.unknown_message": "Съобщението не е намерено.", "unknown_entities.unknown_role": "Ролята не е намерена.", + "unknown_entities.unknown_thread_member": "Членът на нишката не е намерен.", "unknown_entities.unknown_user": "Потребителят не е намерен.", "unknown_entities.unknown_user_flag": "Посоченият потребителски флаг не е разпознат.", "urls.invalid_url_format": "Невалиден URL формат.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Кодът за персонализиран URL адрес трябва да е между {min} и {max} знака.", "vanity_urls.vanity_url_invalid_characters": "Персонализираният URL адрес може да съдържа само малки букви (a-z), цифри (0-9) и тирета (-).", "webhooks.cannot_modify_system_webhook": "Не можеш да редактираш системни съобщения.", + "webhooks.forum_target_conflict": "Не можеш да посочиш едновременно `thread_id` и `thread_name`.", + "webhooks.forum_target_required": "Уебхуковете, които публикуват в този канал, трябва да имат `thread_id` или `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Достигна максимума от {count} {count, plural, one {уебхук} other {уебхука}} за канал.", "webhooks.max_webhooks_per_guild_reached": "Достигна максимума от {count} {count, plural, one {уебхук} other {уебхука}} за общност.", + "webhooks.service_forum_unsupported": "Тази уебхук услуга не може да публикува във форумен или медиен канал без `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` и `applied_tags` работят само във форумни и медийни канали.", "webhooks.unknown_webhook": "Неизвестен уебхук.", "webhooks.webhook_name_length_invalid": "Името на уебхука трябва да е между {min} и {max} знака." } diff --git a/packages/errors/src/i18n/weblate/locales/cs.json b/packages/errors/src/i18n/weblate/locales/cs.json index 685cf9e07..68b0f065c 100644 --- a/packages/errors/src/i18n/weblate/locales/cs.json +++ b/packages/errors/src/i18n/weblate/locales/cs.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Zpracování webhooků není dostupné.", "stripe.webhook_signature_invalid": "Podpis Stripe webhooku je neplatný.", "stripe.webhook_signature_missing": "Chybí podpis Stripe webhooku.", + "threads.channel_has_threads": "Kanály s vlákny nelze převést na oznamovací kanály.", + "threads.forum_tag_names_must_be_unique": "Názvy štítků musí být jedinečné.", + "threads.forum_tag_required": "K vytvoření příspěvku v tomto kanálu je potřeba štítek.", + "threads.hide_media_download_option_media_only": "Skrytí možností stahování médií je dostupné jen v kanálech médií.", + "threads.invalid_thread_notification_settings": "Neplatné nastavení oznámení vlákna.", + "threads.max_active_threads_reached": "Tato komunita dosáhla limitu aktivních vláken: {count}.", + "threads.max_forum_tags_reached": "Tento kanál dosáhl limitu štítků: {count}.", + "threads.max_pinned_threads_reached": "Tento kanál dosáhl limitu připnutých příspěvků: {count}.", + "threads.max_thread_members_reached": "Toto vlákno dosáhlo limitu členů: {count}.", + "threads.no_tags_available_to_non_moderators": "Štítek je povinný, ale v tomto kanálu mohou štítky přidávat jen moderátoři.", + "threads.search_index_not_ready": "Vyhledávací index ještě není připravený. Zkus to za chvíli znovu.", + "threads.thread_already_created_for_message": "Pro tuto zprávu už bylo vlákno vytvořeno.", + "threads.thread_archived": "Toto vlákno je archivované.", + "threads.thread_locked": "Toto vlákno je uzamčené.", "tickets.ticket_already_completed": "Tento tiket už byl dokončen.", "tos_and_age.date_of_birth_format_invalid": "Neplatný formát data narození.", "tos_and_age.must_agree_to_tos_and_privacy": "Musíte souhlasit s podmínkami služby a zásadami ochrany osobních údajů.", "tos_and_age.must_be_minimum_age": "K vytvoření účtu vám musí být alespoň {minAge, plural, one {# rok} few {# roky} many {# roku} other {# let}}.", "unknown_entities.unknown_channel": "Kanál nebyl nalezen.", + "unknown_entities.unknown_forum_tag": "Štítek nebyl nalezen.", "unknown_entities.unknown_guild": "Komunita nebyla nalezena.", "unknown_entities.unknown_invite": "Pozvánka nebyla nalezena nebo už není platná.", "unknown_entities.unknown_member": "Člen nebyl v této komunitě nalezen.", "unknown_entities.unknown_message": "Zpráva nebyla nalezena.", "unknown_entities.unknown_role": "Role nebyla nalezena.", + "unknown_entities.unknown_thread_member": "Člen vlákna nebyl nalezen.", "unknown_entities.unknown_user": "Uživatel nebyl nalezen.", "unknown_entities.unknown_user_flag": "Zadaný uživatelský příznak není rozpoznán.", "urls.invalid_url_format": "Neplatný formát adresy URL.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Kód vlastního odkazu na pozvánku musí mít {min} až {max} znaků.", "vanity_urls.vanity_url_invalid_characters": "Kód vlastního odkazu na pozvánku smí obsahovat pouze malá písmena (a-z), číslice (0-9) a spojovníky (-).", "webhooks.cannot_modify_system_webhook": "Systémové zprávy nelze upravovat.", + "webhooks.forum_target_conflict": "Nemůžeš zadat zároveň `thread_id` i `thread_name`.", + "webhooks.forum_target_required": "Webhooky, které publikují do tohoto kanálu, potřebují `thread_id` nebo `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Dosáhli jste limitu {count, plural, one {# webhooku} few {# webhooků} many {# webhooku} other {# webhooků}} na kanál.", "webhooks.max_webhooks_per_guild_reached": "Dosáhli jste limitu {count, plural, one {# webhooku} few {# webhooků} many {# webhooku} other {# webhooků}} na komunitu.", + "webhooks.service_forum_unsupported": "Tato služba webhooků nemůže bez `thread_id` publikovat do kanálu fóra ani médií.", + "webhooks.thread_name_requires_forum": "`thread_name` a `applied_tags` fungují jen v kanálech fóra a médií.", "webhooks.unknown_webhook": "Neznámý webhook.", "webhooks.webhook_name_length_invalid": "Název webhooku musí být dlouhý {min} až {max} znaků." } diff --git a/packages/errors/src/i18n/weblate/locales/da.json b/packages/errors/src/i18n/weblate/locales/da.json index 81d8ab092..f08eef335 100644 --- a/packages/errors/src/i18n/weblate/locales/da.json +++ b/packages/errors/src/i18n/weblate/locales/da.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhook-behandlingen er ikke tilgængelig.", "stripe.webhook_signature_invalid": "Stripe-webhooksignaturen er ugyldig.", "stripe.webhook_signature_missing": "Stripe-webhooksignaturen mangler.", + "threads.channel_has_threads": "Kanaler med tråde kan ikke konverteres til meddelelseskanaler.", + "threads.forum_tag_names_must_be_unique": "Tagnavne skal være unikke.", + "threads.forum_tag_required": "Der kræves et tag for at oprette et opslag i denne kanal.", + "threads.hide_media_download_option_media_only": "Det er kun i mediekanaler, at downloadmuligheder for medier kan skjules.", + "threads.invalid_thread_notification_settings": "Ugyldige notifikationsindstillinger for tråden.", + "threads.max_active_threads_reached": "Dette fællesskab har nået sin grænse for aktive tråde: {count}.", + "threads.max_forum_tags_reached": "Denne kanal har nået sin grænse for tags: {count}.", + "threads.max_pinned_threads_reached": "Denne kanal har nået sin grænse for fastgjorte opslag: {count}.", + "threads.max_thread_members_reached": "Denne tråd har nået sin grænse for medlemmer: {count}.", + "threads.no_tags_available_to_non_moderators": "Der kræves et tag, men i denne kanal er det kun moderatorer, der kan tilføje tags.", + "threads.search_index_not_ready": "Søgeindekset er ikke klar endnu. Prøv igen om et øjeblik.", + "threads.thread_already_created_for_message": "Der er allerede oprettet en tråd til denne besked.", + "threads.thread_archived": "Denne tråd er arkiveret.", + "threads.thread_locked": "Denne tråd er låst.", "tickets.ticket_already_completed": "Denne sag er allerede afsluttet.", "tos_and_age.date_of_birth_format_invalid": "Ugyldigt fødselsdatoformat.", "tos_and_age.must_agree_to_tos_and_privacy": "Du skal acceptere servicevilkårene og fortrolighedspolitikken.", "tos_and_age.must_be_minimum_age": "Du skal være mindst {minAge} {minAge, plural, one {år} other {år}} gammel for at oprette en konto.", "unknown_entities.unknown_channel": "Kanalen blev ikke fundet.", + "unknown_entities.unknown_forum_tag": "Tagget blev ikke fundet.", "unknown_entities.unknown_guild": "Fællesskabet blev ikke fundet.", "unknown_entities.unknown_invite": "Invitationen blev ikke fundet eller er ikke længere gyldig.", "unknown_entities.unknown_member": "Medlemmet blev ikke fundet i dette fællesskab.", "unknown_entities.unknown_message": "Beskeden blev ikke fundet.", "unknown_entities.unknown_role": "Rollen blev ikke fundet.", + "unknown_entities.unknown_thread_member": "Trådmedlemmet blev ikke fundet.", "unknown_entities.unknown_user": "Brugeren blev ikke fundet.", "unknown_entities.unknown_user_flag": "Det angivne brugerflag genkendes ikke.", "urls.invalid_url_format": "Ugyldigt URL-format.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Koden til det brugerdefinerede invitationslink skal være på mellem {min} og {max} tegn.", "vanity_urls.vanity_url_invalid_characters": "Det brugerdefinerede invitationslink må kun indeholde små bogstaver (a-z), tal (0-9) og bindestreger (-).", "webhooks.cannot_modify_system_webhook": "Systembeskeder kan ikke redigeres.", + "webhooks.forum_target_conflict": "Du kan ikke angive både `thread_id` og `thread_name`.", + "webhooks.forum_target_required": "Webhooks, der poster i denne kanal, skal have et `thread_id` eller et `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Du har nået maksimum på {count} {count, plural, one {webhook} other {webhooks}} pr. kanal.", "webhooks.max_webhooks_per_guild_reached": "Du har nået grænsen på {count, plural, one {# webhook} other {# webhooks}} pr. fællesskab.", + "webhooks.service_forum_unsupported": "Denne webhooktjeneste kan ikke poste i en forum- eller mediekanal uden et `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` og `applied_tags` virker kun i forum- og mediekanaler.", "webhooks.unknown_webhook": "Ukendt webhook.", "webhooks.webhook_name_length_invalid": "Webhooknavnet skal være på mellem {min} og {max} tegn." } diff --git a/packages/errors/src/i18n/weblate/locales/de.json b/packages/errors/src/i18n/weblate/locales/de.json index daadf3a44..6a16bce2a 100644 --- a/packages/errors/src/i18n/weblate/locales/de.json +++ b/packages/errors/src/i18n/weblate/locales/de.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhook-Verarbeitung ist nicht verfügbar.", "stripe.webhook_signature_invalid": "Die Stripe-Webhook-Signatur ist ungültig.", "stripe.webhook_signature_missing": "Die Stripe-Webhook-Signatur fehlt.", + "threads.channel_has_threads": "Kanäle mit Threads können nicht in Ankündigungskanäle umgewandelt werden.", + "threads.forum_tag_names_must_be_unique": "Tag-Namen müssen eindeutig sein.", + "threads.forum_tag_required": "Für einen Beitrag in diesem Kanal ist ein Tag erforderlich.", + "threads.hide_media_download_option_media_only": "Downloadoptionen für Medien lassen sich nur in Medienkanälen ausblenden.", + "threads.invalid_thread_notification_settings": "Ungültige Benachrichtigungseinstellungen für den Thread.", + "threads.max_active_threads_reached": "Diese Community hat ihr Limit für aktive Threads erreicht: {count}.", + "threads.max_forum_tags_reached": "Dieser Kanal hat sein Tag-Limit erreicht: {count}.", + "threads.max_pinned_threads_reached": "Dieser Kanal hat sein Limit für angepinnte Beiträge erreicht: {count}.", + "threads.max_thread_members_reached": "Dieser Thread hat sein Mitgliederlimit erreicht: {count}.", + "threads.no_tags_available_to_non_moderators": "Ein Tag ist erforderlich, aber in diesem Kanal können nur Moderatoren Tags vergeben.", + "threads.search_index_not_ready": "Der Suchindex ist noch nicht bereit. Versuch es gleich noch einmal.", + "threads.thread_already_created_for_message": "Für diese Nachricht wurde bereits ein Thread erstellt.", + "threads.thread_archived": "Dieser Thread ist archiviert.", + "threads.thread_locked": "Dieser Thread ist gesperrt.", "tickets.ticket_already_completed": "Dieses Ticket wurde bereits abgeschlossen.", "tos_and_age.date_of_birth_format_invalid": "Ungültiges Format des Geburtsdatums.", "tos_and_age.must_agree_to_tos_and_privacy": "Du musst den Nutzungsbedingungen und der Datenschutzerklärung zustimmen.", "tos_and_age.must_be_minimum_age": "Du musst mindestens {minAge, plural, one {# Jahr} other {# Jahre}} alt sein, um einen Account zu erstellen.", "unknown_entities.unknown_channel": "Kanal nicht gefunden.", + "unknown_entities.unknown_forum_tag": "Tag nicht gefunden.", "unknown_entities.unknown_guild": "Community nicht gefunden.", "unknown_entities.unknown_invite": "Einladung nicht gefunden oder nicht mehr gültig.", "unknown_entities.unknown_member": "Mitglied in dieser Community nicht gefunden.", "unknown_entities.unknown_message": "Nachricht nicht gefunden.", "unknown_entities.unknown_role": "Rolle nicht gefunden.", + "unknown_entities.unknown_thread_member": "Thread-Mitglied nicht gefunden.", "unknown_entities.unknown_user": "Benutzer nicht gefunden.", "unknown_entities.unknown_user_flag": "Das angegebene Benutzer-Flag wird nicht erkannt.", "urls.invalid_url_format": "Ungültiges Format der URL.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Der Vanity-URL-Code muss zwischen {min} und {max} Zeichen lang sein.", "vanity_urls.vanity_url_invalid_characters": "Die Vanity-URL darf nur Kleinbuchstaben (a-z), Ziffern (0-9) und Bindestriche (-) enthalten.", "webhooks.cannot_modify_system_webhook": "Du kannst keine Systemnachrichten bearbeiten.", + "webhooks.forum_target_conflict": "Du kannst nicht gleichzeitig `thread_id` und `thread_name` angeben.", + "webhooks.forum_target_required": "Webhooks, die in diesem Kanal posten, brauchen eine `thread_id` oder einen `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Du hast das Maximum von {count, plural, one {# Webhook} other {# Webhooks}} pro Kanal erreicht.", "webhooks.max_webhooks_per_guild_reached": "Du hast das Maximum von {count, plural, one {# Webhook} other {# Webhooks}} pro Community erreicht.", + "webhooks.service_forum_unsupported": "Dieser Webhook-Dienst kann ohne `thread_id` nicht in einem Forum- oder Medienkanal posten.", + "webhooks.thread_name_requires_forum": "`thread_name` und `applied_tags` funktionieren nur in Forum- und Medienkanälen.", "webhooks.unknown_webhook": "Unbekannter Webhook.", "webhooks.webhook_name_length_invalid": "Der Webhook-Name muss zwischen {min} und {max} Zeichen lang sein." } diff --git a/packages/errors/src/i18n/weblate/locales/el.json b/packages/errors/src/i18n/weblate/locales/el.json index d013f96a8..30d7440a2 100644 --- a/packages/errors/src/i18n/weblate/locales/el.json +++ b/packages/errors/src/i18n/weblate/locales/el.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Η επεξεργασία webhook δεν είναι διαθέσιμη.", "stripe.webhook_signature_invalid": "Η υπογραφή webhook του Stripe είναι άκυρη.", "stripe.webhook_signature_missing": "Η υπογραφή webhook του Stripe λείπει.", + "threads.channel_has_threads": "Τα κανάλια με νήματα δεν μπορούν να μετατραπούν σε κανάλια ανακοινώσεων.", + "threads.forum_tag_names_must_be_unique": "Τα ονόματα των ετικετών πρέπει να είναι μοναδικά.", + "threads.forum_tag_required": "Για να δημιουργήσεις ανάρτηση σε αυτό το κανάλι χρειάζεται ετικέτα.", + "threads.hide_media_download_option_media_only": "Η απόκρυψη των επιλογών λήψης πολυμέσων είναι διαθέσιμη μόνο σε κανάλια πολυμέσων.", + "threads.invalid_thread_notification_settings": "Μη έγκυρες ρυθμίσεις ειδοποιήσεων νήματος.", + "threads.max_active_threads_reached": "Αυτή η κοινότητα έφτασε το όριο ενεργών νημάτων: {count}.", + "threads.max_forum_tags_reached": "Αυτό το κανάλι έφτασε το όριο ετικετών: {count}.", + "threads.max_pinned_threads_reached": "Αυτό το κανάλι έφτασε το όριο καρφιτσωμένων αναρτήσεων: {count}.", + "threads.max_thread_members_reached": "Αυτό το νήμα έφτασε το όριο μελών: {count}.", + "threads.no_tags_available_to_non_moderators": "Χρειάζεται ετικέτα, αλλά σε αυτό το κανάλι μόνο οι συντονιστές μπορούν να προσθέτουν ετικέτες.", + "threads.search_index_not_ready": "Το ευρετήριο αναζήτησης δεν είναι ακόμα έτοιμο. Δοκίμασε ξανά σε λίγο.", + "threads.thread_already_created_for_message": "Έχει ήδη δημιουργηθεί νήμα για αυτό το μήνυμα.", + "threads.thread_archived": "Αυτό το νήμα είναι αρχειοθετημένο.", + "threads.thread_locked": "Αυτό το νήμα είναι κλειδωμένο.", "tickets.ticket_already_completed": "Αυτό το ticket έχει ήδη ολοκληρωθεί.", "tos_and_age.date_of_birth_format_invalid": "Μη έγκυρη μορφή ημερομηνίας γέννησης.", "tos_and_age.must_agree_to_tos_and_privacy": "Πρέπει να συμφωνήσεις με τους όρους παροχής υπηρεσιών και την πολιτική απορρήτου.", "tos_and_age.must_be_minimum_age": "Πρέπει να είσαι τουλάχιστον {minAge} {minAge, plural, one {έτους} other {ετών}} για να δημιουργήσεις λογαριασμό.", "unknown_entities.unknown_channel": "Δεν βρήκαμε το κανάλι.", + "unknown_entities.unknown_forum_tag": "Δεν βρέθηκε η ετικέτα.", "unknown_entities.unknown_guild": "Δεν βρήκαμε την κοινότητα.", "unknown_entities.unknown_invite": "Δεν βρήκαμε την πρόσκληση ή δεν ισχύει πλέον.", "unknown_entities.unknown_member": "Δεν βρήκαμε το μέλος σε αυτήν την κοινότητα.", "unknown_entities.unknown_message": "Δεν βρήκαμε το μήνυμα.", "unknown_entities.unknown_role": "Δεν βρήκαμε τον ρόλο.", + "unknown_entities.unknown_thread_member": "Δεν βρέθηκε το μέλος του νήματος.", "unknown_entities.unknown_user": "Δεν βρήκαμε τον χρήστη.", "unknown_entities.unknown_user_flag": "Η σημαία χρήστη που καθορίστηκε δεν αναγνωρίζεται.", "urls.invalid_url_format": "Μη έγκυρη μορφή URL.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Ο κωδικός vanity URL πρέπει να είναι μεταξύ {min} και {max} χαρακτήρων.", "vanity_urls.vanity_url_invalid_characters": "Το vanity URL μπορεί να περιέχει μόνο πεζά γράμματα (a-z), ψηφία (0-9) και παύλες (-).", "webhooks.cannot_modify_system_webhook": "Δεν μπορείς να επεξεργαστείς μηνύματα συστήματος.", + "webhooks.forum_target_conflict": "Δεν μπορείς να ορίσεις ταυτόχρονα `thread_id` και `thread_name`.", + "webhooks.forum_target_required": "Τα webhook που δημοσιεύουν σε αυτό το κανάλι χρειάζονται `thread_id` ή `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Έχεις φτάσει το μέγιστο των {count} {count, plural, one {webhook} other {webhook}} ανά κανάλι.", "webhooks.max_webhooks_per_guild_reached": "Έχεις φτάσει το μέγιστο των {count} {count, plural, one {webhook} other {webhook}} ανά κοινότητα.", + "webhooks.service_forum_unsupported": "Αυτή η υπηρεσία webhook δεν μπορεί να δημοσιεύσει σε κανάλι φόρουμ ή πολυμέσων χωρίς `thread_id`.", + "webhooks.thread_name_requires_forum": "Τα `thread_name` και `applied_tags` λειτουργούν μόνο σε κανάλια φόρουμ και πολυμέσων.", "webhooks.unknown_webhook": "Άγνωστο webhook.", "webhooks.webhook_name_length_invalid": "Το όνομα webhook πρέπει να είναι μεταξύ {min} και {max} χαρακτήρων." } diff --git a/packages/errors/src/i18n/weblate/locales/en-GB.json b/packages/errors/src/i18n/weblate/locales/en-GB.json index e8bbc2ef0..32a104736 100644 --- a/packages/errors/src/i18n/weblate/locales/en-GB.json +++ b/packages/errors/src/i18n/weblate/locales/en-GB.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhook processing isn't available.", "stripe.webhook_signature_invalid": "Stripe webhook signature is invalid.", "stripe.webhook_signature_missing": "Stripe webhook signature is missing.", + "threads.channel_has_threads": "Channels with threads can't be converted to announcement channels.", + "threads.forum_tag_names_must_be_unique": "Tag names must be unique.", + "threads.forum_tag_required": "A tag is required to create a post in this channel.", + "threads.hide_media_download_option_media_only": "Hiding media download options is only available in media channels.", + "threads.invalid_thread_notification_settings": "Invalid thread notification settings.", + "threads.max_active_threads_reached": "You've reached the maximum of {count, plural, one {# active thread} other {# active threads}} in this community.", + "threads.max_forum_tags_reached": "You've reached the maximum of {count, plural, one {# tag} other {# tags}} in this channel.", + "threads.max_pinned_threads_reached": "You've reached the maximum of {count, plural, one {# pinned post} other {# pinned posts}} in this channel.", + "threads.max_thread_members_reached": "This thread has reached the maximum of {count, plural, one {# member} other {# members}}.", + "threads.no_tags_available_to_non_moderators": "A tag is required, but only moderators can apply the tags in this channel.", + "threads.search_index_not_ready": "The search index isn't ready yet. Try again in a moment.", + "threads.thread_already_created_for_message": "A thread has already been created for this message.", + "threads.thread_archived": "This thread is archived.", + "threads.thread_locked": "This thread is locked.", "tickets.ticket_already_completed": "This ticket has already been completed.", "tos_and_age.date_of_birth_format_invalid": "Invalid date of birth format.", "tos_and_age.must_agree_to_tos_and_privacy": "You must agree to the Terms of service and Privacy policy.", "tos_and_age.must_be_minimum_age": "You must be at least {minAge, plural, one {# year} other {# years}} old to create an account.", "unknown_entities.unknown_channel": "Channel wasn't found.", + "unknown_entities.unknown_forum_tag": "Tag wasn't found.", "unknown_entities.unknown_guild": "Community wasn't found.", "unknown_entities.unknown_invite": "Invite wasn't found or is no longer valid.", "unknown_entities.unknown_member": "Member wasn't found in this community.", "unknown_entities.unknown_message": "Message wasn't found.", "unknown_entities.unknown_role": "Role wasn't found.", + "unknown_entities.unknown_thread_member": "Thread member wasn't found.", "unknown_entities.unknown_user": "User wasn't found.", "unknown_entities.unknown_user_flag": "The specified user flag isn't recognised.", "urls.invalid_url_format": "Invalid URL format.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Vanity URL code must be between {min} and {max} characters.", "vanity_urls.vanity_url_invalid_characters": "Vanity URL can only contain lowercase letters (a-z), digits (0-9), and hyphens (-).", "webhooks.cannot_modify_system_webhook": "You can't edit system messages.", + "webhooks.forum_target_conflict": "You can't specify both `thread_id` and `thread_name`.", + "webhooks.forum_target_required": "Webhooks posting to this channel need a `thread_id` or a `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per channel.", "webhooks.max_webhooks_per_guild_reached": "You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per community.", + "webhooks.service_forum_unsupported": "This webhook service can't post to a forum or media channel without a `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` and `applied_tags` only work in forum and media channels.", "webhooks.unknown_webhook": "Unknown webhook.", "webhooks.webhook_name_length_invalid": "Webhook name must be between {min} and {max} characters." } diff --git a/packages/errors/src/i18n/weblate/locales/es-419.json b/packages/errors/src/i18n/weblate/locales/es-419.json index 1702b3843..5be46ef0f 100644 --- a/packages/errors/src/i18n/weblate/locales/es-419.json +++ b/packages/errors/src/i18n/weblate/locales/es-419.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "El procesamiento de webhooks no está disponible.", "stripe.webhook_signature_invalid": "La firma del webhook de Stripe no es válida.", "stripe.webhook_signature_missing": "Falta la firma del webhook de Stripe.", + "threads.channel_has_threads": "Los canales con hilos no se pueden convertir en canales de anuncios.", + "threads.forum_tag_names_must_be_unique": "Los nombres de las etiquetas deben ser únicos.", + "threads.forum_tag_required": "Se necesita una etiqueta para crear una publicación en este canal.", + "threads.hide_media_download_option_media_only": "Ocultar las opciones de descarga de contenido multimedia solo está disponible en los canales multimedia.", + "threads.invalid_thread_notification_settings": "La configuración de notificaciones del hilo no es válida.", + "threads.max_active_threads_reached": "Esta comunidad alcanzó su límite de hilos activos: {count}.", + "threads.max_forum_tags_reached": "Este canal alcanzó su límite de etiquetas: {count}.", + "threads.max_pinned_threads_reached": "Este canal alcanzó su límite de publicaciones fijadas: {count}.", + "threads.max_thread_members_reached": "Este hilo alcanzó su límite de miembros: {count}.", + "threads.no_tags_available_to_non_moderators": "Se necesita una etiqueta, pero en este canal solo los moderadores pueden aplicar etiquetas.", + "threads.search_index_not_ready": "El índice de búsqueda todavía no está listo. Vuelve a intentarlo en un momento.", + "threads.thread_already_created_for_message": "Ya se creó un hilo para este mensaje.", + "threads.thread_archived": "Este hilo está archivado.", + "threads.thread_locked": "Este hilo está bloqueado.", "tickets.ticket_already_completed": "Este ticket ya fue completado.", "tos_and_age.date_of_birth_format_invalid": "Formato de fecha de nacimiento inválido.", "tos_and_age.must_agree_to_tos_and_privacy": "Debes aceptar los Términos del servicio y la Política de privacidad.", "tos_and_age.must_be_minimum_age": "Debes tener al menos {minAge} {minAge, plural, one {año} other {años}} para crear una cuenta.", "unknown_entities.unknown_channel": "No se encontró el canal.", + "unknown_entities.unknown_forum_tag": "No se encontró la etiqueta.", "unknown_entities.unknown_guild": "No se encontró la comunidad.", "unknown_entities.unknown_invite": "No se encontró la invitación o ya no es válida.", "unknown_entities.unknown_member": "No se encontró al miembro en esta comunidad.", "unknown_entities.unknown_message": "No se encontró el mensaje.", "unknown_entities.unknown_role": "No se encontró el rol.", + "unknown_entities.unknown_thread_member": "No se encontró al miembro del hilo.", "unknown_entities.unknown_user": "No se encontró al usuario.", "unknown_entities.unknown_user_flag": "El indicador de usuario especificado no se reconoce.", "urls.invalid_url_format": "Formato de URL inválido.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "El código de URL personalizada debe tener entre {min} y {max} caracteres.", "vanity_urls.vanity_url_invalid_characters": "La URL personalizada solo puede contener letras minúsculas (a-z), números (0-9) y guiones (-).", "webhooks.cannot_modify_system_webhook": "No puedes editar mensajes del sistema.", + "webhooks.forum_target_conflict": "No puedes indicar `thread_id` y `thread_name` al mismo tiempo.", + "webhooks.forum_target_required": "Los webhooks que publican en este canal necesitan un `thread_id` o un `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Has alcanzado el máximo de {count} {count, plural, one {webhook} other {webhooks}} por canal.", "webhooks.max_webhooks_per_guild_reached": "Has alcanzado el máximo de {count} {count, plural, one {webhook} other {webhooks}} por comunidad.", + "webhooks.service_forum_unsupported": "Este servicio de webhook no puede publicar en un canal de foro o multimedia sin un `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` y `applied_tags` solo funcionan en canales de foro y multimedia.", "webhooks.unknown_webhook": "Webhook desconocido.", "webhooks.webhook_name_length_invalid": "El nombre del webhook debe tener entre {min} y {max} caracteres." } diff --git a/packages/errors/src/i18n/weblate/locales/es-ES.json b/packages/errors/src/i18n/weblate/locales/es-ES.json index 2b7424d15..9cdbdb719 100644 --- a/packages/errors/src/i18n/weblate/locales/es-ES.json +++ b/packages/errors/src/i18n/weblate/locales/es-ES.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "El procesamiento de webhook no está disponible.", "stripe.webhook_signature_invalid": "La firma del webhook de Stripe no es válida.", "stripe.webhook_signature_missing": "Falta la firma del webhook de Stripe.", + "threads.channel_has_threads": "Los canales con hilos no se pueden convertir en canales de anuncios.", + "threads.forum_tag_names_must_be_unique": "Los nombres de las etiquetas deben ser únicos.", + "threads.forum_tag_required": "Se necesita una etiqueta para crear una publicación en este canal.", + "threads.hide_media_download_option_media_only": "Ocultar las opciones de descarga de contenido multimedia solo está disponible en los canales multimedia.", + "threads.invalid_thread_notification_settings": "La configuración de notificaciones del hilo no es válida.", + "threads.max_active_threads_reached": "Esta comunidad ha alcanzado su límite de hilos activos: {count}.", + "threads.max_forum_tags_reached": "Este canal ha alcanzado su límite de etiquetas: {count}.", + "threads.max_pinned_threads_reached": "Este canal ha alcanzado su límite de publicaciones fijadas: {count}.", + "threads.max_thread_members_reached": "Este hilo ha alcanzado su límite de miembros: {count}.", + "threads.no_tags_available_to_non_moderators": "Se necesita una etiqueta, pero en este canal solo los moderadores pueden aplicar etiquetas.", + "threads.search_index_not_ready": "El índice de búsqueda aún no está listo. Inténtalo de nuevo en un momento.", + "threads.thread_already_created_for_message": "Ya se ha creado un hilo para este mensaje.", + "threads.thread_archived": "Este hilo está archivado.", + "threads.thread_locked": "Este hilo está bloqueado.", "tickets.ticket_already_completed": "Este ticket ya se ha completado.", "tos_and_age.date_of_birth_format_invalid": "Formato de fecha de nacimiento no válido.", "tos_and_age.must_agree_to_tos_and_privacy": "Debes aceptar las Condiciones del servicio y la Política de privacidad.", "tos_and_age.must_be_minimum_age": "Debes tener al menos {minAge} {minAge, plural, one {año} other {años}} de edad para crear una cuenta.", "unknown_entities.unknown_channel": "No se encontró el canal.", + "unknown_entities.unknown_forum_tag": "No se encontró la etiqueta.", "unknown_entities.unknown_guild": "No se encontró la comunidad.", "unknown_entities.unknown_invite": "No se encontró la invitación o ya no es válida.", "unknown_entities.unknown_member": "No se encontró al miembro en esta comunidad.", "unknown_entities.unknown_message": "No se encontró el mensaje.", "unknown_entities.unknown_role": "No se encontró el rol.", + "unknown_entities.unknown_thread_member": "No se encontró al miembro del hilo.", "unknown_entities.unknown_user": "No se encontró el usuario.", "unknown_entities.unknown_user_flag": "El indicador de usuario especificado no se reconoce.", "urls.invalid_url_format": "Formato de URL no válido.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "El código de la URL personalizada debe tener entre {min} y {max} caracteres.", "vanity_urls.vanity_url_invalid_characters": "La URL personalizada solo puede contener letras minúsculas (a-z), números (0-9) y guiones (-).", "webhooks.cannot_modify_system_webhook": "No puedes editar mensajes del sistema.", + "webhooks.forum_target_conflict": "No puedes indicar `thread_id` y `thread_name` a la vez.", + "webhooks.forum_target_required": "Los webhooks que publican en este canal necesitan un `thread_id` o un `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Has alcanzado el máximo de {count} {count, plural, one {webhook} other {webhooks}} por canal.", "webhooks.max_webhooks_per_guild_reached": "Has alcanzado el máximo de {count} {count, plural, one {webhook} other {webhooks}} por comunidad.", + "webhooks.service_forum_unsupported": "Este servicio de webhook no puede publicar en un canal de foro o multimedia sin un `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` y `applied_tags` solo funcionan en canales de foro y multimedia.", "webhooks.unknown_webhook": "Webhook desconocido.", "webhooks.webhook_name_length_invalid": "El nombre del webhook debe tener entre {min} y {max} caracteres." } diff --git a/packages/errors/src/i18n/weblate/locales/fi.json b/packages/errors/src/i18n/weblate/locales/fi.json index 088dca724..8fc170a6e 100644 --- a/packages/errors/src/i18n/weblate/locales/fi.json +++ b/packages/errors/src/i18n/weblate/locales/fi.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhook-käsittely ei ole käytettävissä.", "stripe.webhook_signature_invalid": "Stripe-webhookin allekirjoitus on virheellinen.", "stripe.webhook_signature_missing": "Stripe-webhookin allekirjoitus puuttuu.", + "threads.channel_has_threads": "Ketjuja sisältäviä kanavia ei voi muuttaa tiedotekanaviksi.", + "threads.forum_tag_names_must_be_unique": "Tunnisteiden nimien on oltava yksilöllisiä.", + "threads.forum_tag_required": "Julkaisun luominen tälle kanavalle vaatii tunnisteen.", + "threads.hide_media_download_option_media_only": "Median latausvalinnat voi piilottaa vain mediakanavilla.", + "threads.invalid_thread_notification_settings": "Virheelliset ketjun ilmoitusasetukset.", + "threads.max_active_threads_reached": "Tämä yhteisö on saavuttanut aktiivisten ketjujen rajan: {count}.", + "threads.max_forum_tags_reached": "Tämä kanava on saavuttanut tunnisteiden rajan: {count}.", + "threads.max_pinned_threads_reached": "Tämä kanava on saavuttanut kiinnitettyjen julkaisujen rajan: {count}.", + "threads.max_thread_members_reached": "Tämä ketju on saavuttanut jäsenten rajan: {count}.", + "threads.no_tags_available_to_non_moderators": "Tunniste vaaditaan, mutta tällä kanavalla vain moderaattorit voivat lisätä tunnisteita.", + "threads.search_index_not_ready": "Hakuhakemisto ei ole vielä valmis. Yritä hetken päästä uudelleen.", + "threads.thread_already_created_for_message": "Tälle viestille on jo luotu ketju.", + "threads.thread_archived": "Tämä ketju on arkistoitu.", + "threads.thread_locked": "Tämä ketju on lukittu.", "tickets.ticket_already_completed": "Tämä asia on jo käsitelty.", "tos_and_age.date_of_birth_format_invalid": "Virheellinen syntymäaikamuoto.", "tos_and_age.must_agree_to_tos_and_privacy": "Sinun on hyväksyttävä käyttöehdot ja tietosuojakäytäntö.", "tos_and_age.must_be_minimum_age": "Sinun on oltava vähintään {minAge, plural, one {# vuoden} other {# vuotta}} ikäinen luodaksesi tilin.", "unknown_entities.unknown_channel": "Kanavaa ei löytynyt.", + "unknown_entities.unknown_forum_tag": "Tunnistetta ei löytynyt.", "unknown_entities.unknown_guild": "Yhteisöä ei löytynyt.", "unknown_entities.unknown_invite": "Kutsua ei löytynyt tai se ei ole enää voimassa.", "unknown_entities.unknown_member": "Jäsentä ei löytynyt tästä yhteisöstä.", "unknown_entities.unknown_message": "Viestiä ei löytynyt.", "unknown_entities.unknown_role": "Roolia ei löytynyt.", + "unknown_entities.unknown_thread_member": "Ketjun jäsentä ei löytynyt.", "unknown_entities.unknown_user": "Käyttäjää ei löytynyt.", "unknown_entities.unknown_user_flag": "Määritettyä käyttäjälippua ei tunnisteta.", "urls.invalid_url_format": "Virheellinen URL-osoite.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Mukautetun URL-osoitteen koodin on oltava {min}–{max} merkkiä pitkä.", "vanity_urls.vanity_url_invalid_characters": "Mukautettu URL-osoite voi sisältää vain pieniä kirjaimia (a-z), numeroita (0-9) ja yhdysmerkkejä (-).", "webhooks.cannot_modify_system_webhook": "Järjestelmäviestejä ei voi muokata.", + "webhooks.forum_target_conflict": "Et voi antaa sekä kenttää `thread_id` että kenttää `thread_name`.", + "webhooks.forum_target_required": "Tälle kanavalle julkaisevat webhookit tarvitsevat kentän `thread_id` tai `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Kanavan webhookien enimmäismäärä on saavutettu: {count, plural, one {# webhook} other {# webhookia}}.", "webhooks.max_webhooks_per_guild_reached": "Yhteisön webhookien enimmäismäärä on saavutettu: {count, plural, one {# webhook} other {# webhookia}}.", + "webhooks.service_forum_unsupported": "Tämä webhook-palvelu ei voi julkaista foorumi- tai mediakanavalle ilman kenttää `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` ja `applied_tags` toimivat vain foorumi- ja mediakanavilla.", "webhooks.unknown_webhook": "Tuntematon webhook.", "webhooks.webhook_name_length_invalid": "Webhookin nimen on oltava {min}–{max} merkkiä pitkä." } diff --git a/packages/errors/src/i18n/weblate/locales/fr.json b/packages/errors/src/i18n/weblate/locales/fr.json index 6a758fe60..e9e7a6027 100644 --- a/packages/errors/src/i18n/weblate/locales/fr.json +++ b/packages/errors/src/i18n/weblate/locales/fr.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Traitement du webhook indisponible.", "stripe.webhook_signature_invalid": "La signature du webhook Stripe est invalide.", "stripe.webhook_signature_missing": "La signature du webhook Stripe est manquante.", + "threads.channel_has_threads": "Les salons contenant des fils ne peuvent pas être convertis en salons d'annonces.", + "threads.forum_tag_names_must_be_unique": "Les noms des tags doivent être uniques.", + "threads.forum_tag_required": "Un tag est requis pour créer une publication dans ce salon.", + "threads.hide_media_download_option_media_only": "Le masquage des options de téléchargement des médias n'est disponible que dans les salons multimédias.", + "threads.invalid_thread_notification_settings": "Paramètres de notification du fil non valides.", + "threads.max_active_threads_reached": "Cette communauté a atteint sa limite de fils actifs : {count}.", + "threads.max_forum_tags_reached": "Ce salon a atteint sa limite de tags : {count}.", + "threads.max_pinned_threads_reached": "Ce salon a atteint sa limite de publications épinglées : {count}.", + "threads.max_thread_members_reached": "Ce fil a atteint sa limite de membres : {count}.", + "threads.no_tags_available_to_non_moderators": "Un tag est requis, mais seuls les modérateurs peuvent appliquer les tags de ce salon.", + "threads.search_index_not_ready": "L'index de recherche n'est pas encore prêt. Réessaie dans un instant.", + "threads.thread_already_created_for_message": "Un fil a déjà été créé pour ce message.", + "threads.thread_archived": "Ce fil est archivé.", + "threads.thread_locked": "Ce fil est verrouillé.", "tickets.ticket_already_completed": "Ce ticket a déjà été traité.", "tos_and_age.date_of_birth_format_invalid": "Format de date de naissance invalide.", "tos_and_age.must_agree_to_tos_and_privacy": "Vous devez accepter les conditions d’utilisation et la politique de confidentialité.", "tos_and_age.must_be_minimum_age": "Vous devez avoir au moins {minAge, plural, =1 {# an} other {# ans}} pour créer un compte.", "unknown_entities.unknown_channel": "Salon introuvable.", + "unknown_entities.unknown_forum_tag": "Tag introuvable.", "unknown_entities.unknown_guild": "Communauté introuvable.", "unknown_entities.unknown_invite": "Cette invitation est introuvable ou n’est plus valide.", "unknown_entities.unknown_member": "Membre introuvable dans cette communauté.", "unknown_entities.unknown_message": "Message introuvable.", "unknown_entities.unknown_role": "Rôle introuvable.", + "unknown_entities.unknown_thread_member": "Membre du fil introuvable.", "unknown_entities.unknown_user": "Utilisateur introuvable.", "unknown_entities.unknown_user_flag": "L’indicateur utilisateur spécifié n’est pas reconnu.", "urls.invalid_url_format": "Format d'URL invalide.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Le code d'URL personnalisée doit comporter entre {min} et {max} caractères.", "vanity_urls.vanity_url_invalid_characters": "L'URL personnalisée ne peut contenir que des lettres minuscules (a-z), des chiffres (0-9) et des tirets (-).", "webhooks.cannot_modify_system_webhook": "Vous ne pouvez pas modifier les messages système.", + "webhooks.forum_target_conflict": "Tu ne peux pas indiquer à la fois `thread_id` et `thread_name`.", + "webhooks.forum_target_required": "Les webhooks qui publient dans ce salon doivent fournir un `thread_id` ou un `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Vous avez atteint le maximum de {count, plural, =1 {# webhook} other {# webhooks}} par salon.", "webhooks.max_webhooks_per_guild_reached": "Vous avez atteint le maximum de {count, plural, =1 {# webhook} other {# webhooks}} par communauté.", + "webhooks.service_forum_unsupported": "Ce service de webhook ne peut pas publier dans un salon forum ou multimédia sans `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` et `applied_tags` ne fonctionnent que dans les salons forum et multimédias.", "webhooks.unknown_webhook": "Webhook inconnu.", "webhooks.webhook_name_length_invalid": "Le nom du webhook doit comporter entre {min} et {max} caractères." } diff --git a/packages/errors/src/i18n/weblate/locales/he.json b/packages/errors/src/i18n/weblate/locales/he.json index 1c0856611..c4fadd76a 100644 --- a/packages/errors/src/i18n/weblate/locales/he.json +++ b/packages/errors/src/i18n/weblate/locales/he.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "עיבוד ה-webhook אינו זמין.", "stripe.webhook_signature_invalid": "חתימת ה-webhook של Stripe שגויה.", "stripe.webhook_signature_missing": "חתימת ה-webhook של Stripe חסרה.", + "threads.channel_has_threads": "לא ניתן להמיר ערוצים עם שרשורים לערוצי הכרזות.", + "threads.forum_tag_names_must_be_unique": "שמות התגיות חייבים להיות ייחודיים.", + "threads.forum_tag_required": "כדי ליצור פוסט בערוץ הזה צריך תגית.", + "threads.hide_media_download_option_media_only": "הסתרת אפשרויות ההורדה של מדיה זמינה רק בערוצי מדיה.", + "threads.invalid_thread_notification_settings": "הגדרות ההתראות של השרשור אינן תקינות.", + "threads.max_active_threads_reached": "הקהילה הזו הגיעה למגבלת השרשורים הפעילים: {count}.", + "threads.max_forum_tags_reached": "הערוץ הזה הגיע למגבלת התגיות: {count}.", + "threads.max_pinned_threads_reached": "הערוץ הזה הגיע למגבלת הפוסטים המוצמדים: {count}.", + "threads.max_thread_members_reached": "השרשור הזה הגיע למגבלת החברים: {count}.", + "threads.no_tags_available_to_non_moderators": "צריך תגית, אבל בערוץ הזה רק מנחים יכולים להוסיף תגיות.", + "threads.search_index_not_ready": "אינדקס החיפוש עדיין לא מוכן. נסו שוב בעוד רגע.", + "threads.thread_already_created_for_message": "כבר נוצר שרשור להודעה הזו.", + "threads.thread_archived": "השרשור הזה בארכיון.", + "threads.thread_locked": "השרשור הזה נעול.", "tickets.ticket_already_completed": "כרטיס זה כבר הושלם.", "tos_and_age.date_of_birth_format_invalid": "פורמט תאריך לידה שגוי.", "tos_and_age.must_agree_to_tos_and_privacy": "יש להסכים לתנאי השירות ולמדיניות הפרטיות.", "tos_and_age.must_be_minimum_age": "יש להיות בגיל {minAge} {minAge, plural, one {שנה} other {שנים}} לפחות כדי ליצור חשבון.", "unknown_entities.unknown_channel": "הערוץ לא נמצא.", + "unknown_entities.unknown_forum_tag": "התגית לא נמצאה.", "unknown_entities.unknown_guild": "הקהילה לא נמצאה.", "unknown_entities.unknown_invite": "ההזמנה לא נמצאה או שאינה תקפה יותר.", "unknown_entities.unknown_member": "החבר לא נמצא בקהילה זו.", "unknown_entities.unknown_message": "ההודעה לא נמצאה.", "unknown_entities.unknown_role": "התפקיד לא נמצא.", + "unknown_entities.unknown_thread_member": "חבר השרשור לא נמצא.", "unknown_entities.unknown_user": "המשתמש לא נמצא.", "unknown_entities.unknown_user_flag": "דגל המשתמש שצוין אינו מזוהה.", "urls.invalid_url_format": "פורמט כתובת אתר שגוי.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "קוד כתובת ה-URL המותאמת אישית חייב להיות באורך של {min} עד {max} תווים.", "vanity_urls.vanity_url_invalid_characters": "כתובת ה-URL המותאמת אישית יכולה להכיל רק אותיות קטנות (a-z), ספרות (0-9) ומקפים (-).", "webhooks.cannot_modify_system_webhook": "לא ניתן לערוך הודעות מערכת.", + "webhooks.forum_target_conflict": "אי אפשר לציין גם `thread_id` וגם `thread_name`.", + "webhooks.forum_target_required": "webhooks שמפרסמים בערוץ הזה צריכים `thread_id` או `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "הגעת למספר המרבי של {count} {count, plural, one {webhook} other {webhooks}} לערוץ.", "webhooks.max_webhooks_per_guild_reached": "הגעת למספר המרבי של {count} {count, plural, one {webhook} other {webhooks}} לקהילה.", + "webhooks.service_forum_unsupported": "שירות ה-webhook הזה לא יכול לפרסם בערוץ פורום או מדיה בלי `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` ו-`applied_tags` עובדים רק בערוצי פורום ומדיה.", "webhooks.unknown_webhook": "webhook לא ידוע.", "webhooks.webhook_name_length_invalid": "שם ה-webhook חייב להיות באורך של {min} עד {max} תווים." } diff --git a/packages/errors/src/i18n/weblate/locales/hi.json b/packages/errors/src/i18n/weblate/locales/hi.json index 1ec1cec38..7029e5d5a 100644 --- a/packages/errors/src/i18n/weblate/locales/hi.json +++ b/packages/errors/src/i18n/weblate/locales/hi.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "वेबहुक प्रोसेसिंग उपलब्ध नहीं है।", "stripe.webhook_signature_invalid": "Stripe वेबहुक सिग्नेचर अमान्य है।", "stripe.webhook_signature_missing": "Stripe वेबहुक सिग्नेचर मौजूद नहीं है।", + "threads.channel_has_threads": "थ्रेड वाले चैनल घोषणा चैनल में नहीं बदले जा सकते।", + "threads.forum_tag_names_must_be_unique": "टैग के नाम अलग-अलग होने चाहिए।", + "threads.forum_tag_required": "इस चैनल में पोस्ट बनाने के लिए एक टैग ज़रूरी है।", + "threads.hide_media_download_option_media_only": "मीडिया डाउनलोड विकल्प छिपाना केवल मीडिया चैनलों में उपलब्ध है।", + "threads.invalid_thread_notification_settings": "थ्रेड की सूचना सेटिंग्स अमान्य हैं।", + "threads.max_active_threads_reached": "यह कम्युनिटी सक्रिय थ्रेड की सीमा तक पहुँच गई है: {count}।", + "threads.max_forum_tags_reached": "यह चैनल टैग की सीमा तक पहुँच गया है: {count}।", + "threads.max_pinned_threads_reached": "यह चैनल पिन की गई पोस्ट की सीमा तक पहुँच गया है: {count}।", + "threads.max_thread_members_reached": "यह थ्रेड सदस्यों की सीमा तक पहुँच गया है: {count}।", + "threads.no_tags_available_to_non_moderators": "टैग ज़रूरी है, लेकिन इस चैनल में केवल मॉडरेटर ही टैग लगा सकते हैं।", + "threads.search_index_not_ready": "सर्च इंडेक्स अभी तैयार नहीं है। थोड़ी देर में फिर कोशिश करें।", + "threads.thread_already_created_for_message": "इस मैसेज के लिए पहले ही एक थ्रेड बनाया जा चुका है।", + "threads.thread_archived": "यह थ्रेड आर्काइव किया गया है।", + "threads.thread_locked": "यह थ्रेड लॉक है।", "tickets.ticket_already_completed": "यह टिकट पहले ही पूरा हो चुका है।", "tos_and_age.date_of_birth_format_invalid": "जन्म तिथि का फ़ॉर्मेट अमान्य है।", "tos_and_age.must_agree_to_tos_and_privacy": "आपको सेवा की शर्तों और गोपनीयता नीति से सहमत होना होगा।", "tos_and_age.must_be_minimum_age": "अकाउंट बनाने के लिए आपकी उम्र कम से कम {minAge} {minAge, plural, one {साल} other {साल}} होनी चाहिए।", "unknown_entities.unknown_channel": "चैनल नहीं मिला।", + "unknown_entities.unknown_forum_tag": "टैग नहीं मिला।", "unknown_entities.unknown_guild": "कम्युनिटी नहीं मिली।", "unknown_entities.unknown_invite": "इनवाइट नहीं मिला या अब मान्य नहीं है।", "unknown_entities.unknown_member": "इस कम्युनिटी में सदस्य नहीं मिला।", "unknown_entities.unknown_message": "मैसेज नहीं मिला।", "unknown_entities.unknown_role": "रोल नहीं मिला।", + "unknown_entities.unknown_thread_member": "थ्रेड सदस्य नहीं मिला।", "unknown_entities.unknown_user": "यूज़र नहीं मिला।", "unknown_entities.unknown_user_flag": "यह यूज़र फ़्लैग पहचाना नहीं गया।", "urls.invalid_url_format": "अमान्य URL फ़ॉर्मेट।", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "वैनिटी URL कोड {min} और {max} कैरेक्टर के बीच होना चाहिए।", "vanity_urls.vanity_url_invalid_characters": "वैनिटी URL में केवल छोटे अक्षर (a-z), डिजिट (0-9) और हाइफ़न (-) हो सकते हैं।", "webhooks.cannot_modify_system_webhook": "आप सिस्टम मैसेज एडिट नहीं कर सकते।", + "webhooks.forum_target_conflict": "आप `thread_id` और `thread_name` दोनों एक साथ नहीं दे सकते।", + "webhooks.forum_target_required": "इस चैनल में पोस्ट करने वाले वेबहुक को `thread_id` या `thread_name` चाहिए।", "webhooks.max_webhooks_per_channel_reached": "आप एक चैनल के लिए अधिकतम {count} {count, plural, one {वेबहुक} other {वेबहुक}} तक पहुँच गए हैं।", "webhooks.max_webhooks_per_guild_reached": "आप एक कम्युनिटी के लिए अधिकतम {count} {count, plural, one {वेबहुक} other {वेबहुक}} तक पहुँच गए हैं।", + "webhooks.service_forum_unsupported": "यह वेबहुक सेवा `thread_id` के बिना फ़ोरम या मीडिया चैनल में पोस्ट नहीं कर सकती।", + "webhooks.thread_name_requires_forum": "`thread_name` और `applied_tags` केवल फ़ोरम और मीडिया चैनलों में काम करते हैं।", "webhooks.unknown_webhook": "अज्ञात वेबहुक।", "webhooks.webhook_name_length_invalid": "वेबहुक नेम {min} और {max} कैरेक्टर के बीच होना चाहिए।" } diff --git a/packages/errors/src/i18n/weblate/locales/hr.json b/packages/errors/src/i18n/weblate/locales/hr.json index 117c9b4cc..f97618fce 100644 --- a/packages/errors/src/i18n/weblate/locales/hr.json +++ b/packages/errors/src/i18n/weblate/locales/hr.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Obrada webhooka nije dostupna.", "stripe.webhook_signature_invalid": "Potpis Stripe webhooka je nevažeći.", "stripe.webhook_signature_missing": "Nedostaje potpis Stripe webhooka.", + "threads.channel_has_threads": "Kanali s nitima ne mogu se pretvoriti u kanale za obavijesti.", + "threads.forum_tag_names_must_be_unique": "Nazivi oznaka moraju biti jedinstveni.", + "threads.forum_tag_required": "Za objavu u ovom kanalu potrebna je oznaka.", + "threads.hide_media_download_option_media_only": "Skrivanje opcija preuzimanja medija dostupno je samo u medijskim kanalima.", + "threads.invalid_thread_notification_settings": "Nevažeće postavke obavijesti niti.", + "threads.max_active_threads_reached": "Ova zajednica dosegnula je ograničenje aktivnih niti: {count}.", + "threads.max_forum_tags_reached": "Ovaj kanal dosegnuo je ograničenje oznaka: {count}.", + "threads.max_pinned_threads_reached": "Ovaj kanal dosegnuo je ograničenje prikvačenih objava: {count}.", + "threads.max_thread_members_reached": "Ova nit dosegnula je ograničenje članova: {count}.", + "threads.no_tags_available_to_non_moderators": "Oznaka je obavezna, ali u ovom kanalu oznake mogu dodavati samo moderatori.", + "threads.search_index_not_ready": "Indeks pretraživanja još nije spreman. Pokušaj ponovno za trenutak.", + "threads.thread_already_created_for_message": "Za ovu poruku već je stvorena nit.", + "threads.thread_archived": "Ova nit je arhivirana.", + "threads.thread_locked": "Ova nit je zaključana.", "tickets.ticket_already_completed": "Ovaj je tiket već dovršen.", "tos_and_age.date_of_birth_format_invalid": "Nevažeći format datuma rođenja.", "tos_and_age.must_agree_to_tos_and_privacy": "Moraš prihvatiti Uvjete pružanja usluge i Pravila privatnosti.", "tos_and_age.must_be_minimum_age": "Moraš imati najmanje {minAge, plural, one {# godinu} few {# godine} other {# godina}} da otvoriš račun.", "unknown_entities.unknown_channel": "Kanal nije pronađen.", + "unknown_entities.unknown_forum_tag": "Oznaka nije pronađena.", "unknown_entities.unknown_guild": "Zajednica nije pronađena.", "unknown_entities.unknown_invite": "Pozivnica nije pronađena ili više nije važeća.", "unknown_entities.unknown_member": "Član nije pronađen u ovoj zajednici.", "unknown_entities.unknown_message": "Poruka nije pronađena.", "unknown_entities.unknown_role": "Uloga nije pronađena.", + "unknown_entities.unknown_thread_member": "Član niti nije pronađen.", "unknown_entities.unknown_user": "Korisnik nije pronađen.", "unknown_entities.unknown_user_flag": "Navedena korisnička zastavica nije prepoznata.", "urls.invalid_url_format": "Nevažeći format URL-a.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Kod prilagođenog URL-a mora biti između {min} i {max} znakova.", "vanity_urls.vanity_url_invalid_characters": "Prilagođeni URL može sadržavati samo mala slova (a-z), znamenke (0-9) i crtice (-).", "webhooks.cannot_modify_system_webhook": "Ne možeš uređivati sistemske poruke.", + "webhooks.forum_target_conflict": "Ne možeš navesti i `thread_id` i `thread_name`.", + "webhooks.forum_target_required": "Webhookovi koji objavljuju u ovom kanalu trebaju `thread_id` ili `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Dosegnut je maksimum od {count, plural, one {# webhooka} few {# webhooka} other {# webhookova}} po kanalu.", "webhooks.max_webhooks_per_guild_reached": "Dosegnut je maksimum od {count, plural, one {# webhooka} few {# webhooka} other {# webhookova}} po zajednici.", + "webhooks.service_forum_unsupported": "Ova webhook usluga ne može objavljivati u forumskom ili medijskom kanalu bez `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` i `applied_tags` rade samo u forumskim i medijskim kanalima.", "webhooks.unknown_webhook": "Nepoznat webhook.", "webhooks.webhook_name_length_invalid": "Naziv webhooka mora biti između {min} i {max} znakova." } diff --git a/packages/errors/src/i18n/weblate/locales/hu.json b/packages/errors/src/i18n/weblate/locales/hu.json index 99437edc3..472a24dc0 100644 --- a/packages/errors/src/i18n/weblate/locales/hu.json +++ b/packages/errors/src/i18n/weblate/locales/hu.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "A webhook feldolgozása nem működik.", "stripe.webhook_signature_invalid": "A Stripe-webhook aláírása érvénytelen.", "stripe.webhook_signature_missing": "A Stripe-webhook aláírása hiányzik.", + "threads.channel_has_threads": "A szálakat tartalmazó csatornák nem alakíthatók át bejelentési csatornává.", + "threads.forum_tag_names_must_be_unique": "A címkék nevének egyedinek kell lennie.", + "threads.forum_tag_required": "Ebben a csatornában bejegyzés létrehozásához címke szükséges.", + "threads.hide_media_download_option_media_only": "A médialetöltési lehetőségek elrejtése csak médiacsatornákban érhető el.", + "threads.invalid_thread_notification_settings": "Érvénytelen szálértesítési beállítások.", + "threads.max_active_threads_reached": "Ez a közösség elérte az aktív szálak korlátját: {count}.", + "threads.max_forum_tags_reached": "Ez a csatorna elérte a címkék korlátját: {count}.", + "threads.max_pinned_threads_reached": "Ez a csatorna elérte a kitűzött bejegyzések korlátját: {count}.", + "threads.max_thread_members_reached": "Ez a szál elérte a tagok korlátját: {count}.", + "threads.no_tags_available_to_non_moderators": "Címke szükséges, de ebben a csatornában csak moderátorok adhatnak hozzá címkét.", + "threads.search_index_not_ready": "A keresési index még nem áll készen. Próbáld újra egy pillanat múlva.", + "threads.thread_already_created_for_message": "Ehhez az üzenethez már létrehoztak egy szálat.", + "threads.thread_archived": "Ez a szál archiválva van.", + "threads.thread_locked": "Ez a szál zárolva van.", "tickets.ticket_already_completed": "Ezt a hibajegyet már lezárták.", "tos_and_age.date_of_birth_format_invalid": "A születési dátum formátuma érvénytelen.", "tos_and_age.must_agree_to_tos_and_privacy": "El kell fogadnod a Szolgáltatási feltételeket és az Adatvédelmi irányelveket.", "tos_and_age.must_be_minimum_age": "Legalább {minAge, plural, one {# évesnek} other {# évesnek}} kell lenned ahhoz, hogy fiókot hozz létre.", "unknown_entities.unknown_channel": "A csatorna nem található.", + "unknown_entities.unknown_forum_tag": "A címke nem található.", "unknown_entities.unknown_guild": "A közösség nem található.", "unknown_entities.unknown_invite": "A meghívó nem található, vagy már nem érvényes.", "unknown_entities.unknown_member": "A tag nem található ebben a közösségben.", "unknown_entities.unknown_message": "Az üzenet nem található.", "unknown_entities.unknown_role": "A szerepkör nem található.", + "unknown_entities.unknown_thread_member": "A szál tagja nem található.", "unknown_entities.unknown_user": "A felhasználó nem található.", "unknown_entities.unknown_user_flag": "A megadott felhasználói jelző nem ismert.", "urls.invalid_url_format": "Az URL formátuma érvénytelen.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Az egyéni meghívólink kódja {min}–{max} karakter hosszú lehet.", "vanity_urls.vanity_url_invalid_characters": "Az egyéni meghívólink csak kisbetűket (a-z), számokat (0-9) és kötőjeleket (-) tartalmazhat.", "webhooks.cannot_modify_system_webhook": "Rendszerüzeneteket nem szerkeszthetsz.", + "webhooks.forum_target_conflict": "Nem adhatod meg egyszerre a `thread_id` és a `thread_name` mezőt.", + "webhooks.forum_target_required": "Az ebbe a csatornába posztoló webhookoknak `thread_id` vagy `thread_name` szükséges.", "webhooks.max_webhooks_per_channel_reached": "Elérted a csatornánként megengedett webhookok számát ({count, plural, one {#} other {#}}).", "webhooks.max_webhooks_per_guild_reached": "Elérted a közösségenként megengedett webhookok számát ({count, plural, one {#} other {#}}).", + "webhooks.service_forum_unsupported": "Ez a webhookszolgáltatás `thread_id` nélkül nem tud fórum- vagy médiacsatornába posztolni.", + "webhooks.thread_name_requires_forum": "A `thread_name` és az `applied_tags` csak fórum- és médiacsatornákban működik.", "webhooks.unknown_webhook": "Ismeretlen webhook.", "webhooks.webhook_name_length_invalid": "A webhook neve {min}–{max} karakter hosszú lehet." } diff --git a/packages/errors/src/i18n/weblate/locales/id.json b/packages/errors/src/i18n/weblate/locales/id.json index de94f2e78..624c34b83 100644 --- a/packages/errors/src/i18n/weblate/locales/id.json +++ b/packages/errors/src/i18n/weblate/locales/id.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Pemrosesan webhook tidak tersedia.", "stripe.webhook_signature_invalid": "Tanda tangan webhook Stripe tidak valid.", "stripe.webhook_signature_missing": "Tanda tangan webhook Stripe tidak ada.", + "threads.channel_has_threads": "Saluran yang memiliki utas tidak bisa diubah menjadi saluran pengumuman.", + "threads.forum_tag_names_must_be_unique": "Nama tag harus unik.", + "threads.forum_tag_required": "Diperlukan tag untuk membuat postingan di saluran ini.", + "threads.hide_media_download_option_media_only": "Menyembunyikan opsi unduhan media hanya tersedia di saluran media.", + "threads.invalid_thread_notification_settings": "Pengaturan notifikasi utas tidak valid.", + "threads.max_active_threads_reached": "Komunitas ini sudah mencapai batas utas aktif: {count}.", + "threads.max_forum_tags_reached": "Saluran ini sudah mencapai batas tag: {count}.", + "threads.max_pinned_threads_reached": "Saluran ini sudah mencapai batas postingan yang disematkan: {count}.", + "threads.max_thread_members_reached": "Utas ini sudah mencapai batas anggota: {count}.", + "threads.no_tags_available_to_non_moderators": "Tag diperlukan, tetapi di saluran ini hanya moderator yang bisa menambahkan tag.", + "threads.search_index_not_ready": "Indeks pencarian belum siap. Coba lagi sebentar lagi.", + "threads.thread_already_created_for_message": "Utas untuk pesan ini sudah dibuat.", + "threads.thread_archived": "Utas ini diarsipkan.", + "threads.thread_locked": "Utas ini dikunci.", "tickets.ticket_already_completed": "Tiket ini sudah selesai.", "tos_and_age.date_of_birth_format_invalid": "Format tanggal lahir tidak valid.", "tos_and_age.must_agree_to_tos_and_privacy": "Kamu harus menyetujui Ketentuan layanan dan Kebijakan privasi.", "tos_and_age.must_be_minimum_age": "Kamu harus berusia minimal {minAge} {minAge, plural, other {tahun}} untuk membuat akun.", "unknown_entities.unknown_channel": "Saluran tidak ditemukan.", + "unknown_entities.unknown_forum_tag": "Tag tidak ditemukan.", "unknown_entities.unknown_guild": "Komunitas tidak ditemukan.", "unknown_entities.unknown_invite": "Undangan tidak ditemukan atau sudah tidak valid.", "unknown_entities.unknown_member": "Anggota tidak ditemukan di komunitas ini.", "unknown_entities.unknown_message": "Pesan tidak ditemukan.", "unknown_entities.unknown_role": "Peran tidak ditemukan.", + "unknown_entities.unknown_thread_member": "Anggota utas tidak ditemukan.", "unknown_entities.unknown_user": "Pengguna tidak ditemukan.", "unknown_entities.unknown_user_flag": "Flag pengguna yang ditentukan tidak dikenali.", "urls.invalid_url_format": "Format URL tidak valid.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Kode URL kustom harus antara {min} dan {max} karakter.", "vanity_urls.vanity_url_invalid_characters": "URL kustom hanya boleh berisi huruf kecil (a-z), angka (0-9), dan tanda hubung (-).", "webhooks.cannot_modify_system_webhook": "Kamu tidak bisa mengedit pesan sistem.", + "webhooks.forum_target_conflict": "Kamu tidak bisa menentukan `thread_id` dan `thread_name` sekaligus.", + "webhooks.forum_target_required": "Webhook yang memposting ke saluran ini memerlukan `thread_id` atau `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Kamu telah mencapai batas maksimal {count} {count, plural, other {webhook}} per saluran.", "webhooks.max_webhooks_per_guild_reached": "Kamu telah mencapai batas maksimal {count} {count, plural, other {webhook}} per komunitas.", + "webhooks.service_forum_unsupported": "Layanan webhook ini tidak bisa memposting ke saluran forum atau media tanpa `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` dan `applied_tags` hanya berfungsi di saluran forum dan media.", "webhooks.unknown_webhook": "Webhook tidak dikenal.", "webhooks.webhook_name_length_invalid": "Nama webhook harus antara {min} dan {max} karakter." } diff --git a/packages/errors/src/i18n/weblate/locales/it.json b/packages/errors/src/i18n/weblate/locales/it.json index 3379c51fe..f8b7e2977 100644 --- a/packages/errors/src/i18n/weblate/locales/it.json +++ b/packages/errors/src/i18n/weblate/locales/it.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "L'elaborazione webhook non è disponibile.", "stripe.webhook_signature_invalid": "La firma del webhook Stripe non è valida.", "stripe.webhook_signature_missing": "Manca la firma del webhook Stripe.", + "threads.channel_has_threads": "I canali con thread non possono essere convertiti in canali annunci.", + "threads.forum_tag_names_must_be_unique": "I nomi dei tag devono essere univoci.", + "threads.forum_tag_required": "Per creare un post in questo canale serve un tag.", + "threads.hide_media_download_option_media_only": "Nascondere le opzioni di download dei media è possibile solo nei canali multimediali.", + "threads.invalid_thread_notification_settings": "Impostazioni di notifica del thread non valide.", + "threads.max_active_threads_reached": "Questa community ha raggiunto il limite di thread attivi: {count}.", + "threads.max_forum_tags_reached": "Questo canale ha raggiunto il limite di tag: {count}.", + "threads.max_pinned_threads_reached": "Questo canale ha raggiunto il limite di post fissati: {count}.", + "threads.max_thread_members_reached": "Questo thread ha raggiunto il limite di membri: {count}.", + "threads.no_tags_available_to_non_moderators": "Serve un tag, ma in questo canale solo i moderatori possono applicare i tag.", + "threads.search_index_not_ready": "L'indice di ricerca non è ancora pronto. Riprova tra un attimo.", + "threads.thread_already_created_for_message": "Per questo messaggio è già stato creato un thread.", + "threads.thread_archived": "Questo thread è archiviato.", + "threads.thread_locked": "Questo thread è bloccato.", "tickets.ticket_already_completed": "Questo ticket è già stato completato.", "tos_and_age.date_of_birth_format_invalid": "Formato data di nascita non valido.", "tos_and_age.must_agree_to_tos_and_privacy": "Devi accettare i Termini di servizio e l'Informativa sulla privacy.", "tos_and_age.must_be_minimum_age": "Devi avere almeno {minAge, plural, one {# anno} other {# anni}} per creare un account.", "unknown_entities.unknown_channel": "Canale non trovato.", + "unknown_entities.unknown_forum_tag": "Tag non trovato.", "unknown_entities.unknown_guild": "Community non trovata.", "unknown_entities.unknown_invite": "Invito non trovato o non più valido.", "unknown_entities.unknown_member": "Membro non trovato in questa community.", "unknown_entities.unknown_message": "Messaggio non trovato.", "unknown_entities.unknown_role": "Ruolo non trovato.", + "unknown_entities.unknown_thread_member": "Membro del thread non trovato.", "unknown_entities.unknown_user": "Utente non trovato.", "unknown_entities.unknown_user_flag": "Il flag utente specificato non è riconosciuto.", "urls.invalid_url_format": "Formato URL non valido.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Il codice dell’URL personalizzato deve contenere da {min} a {max} caratteri.", "vanity_urls.vanity_url_invalid_characters": "L'URL personalizzato può contenere solo lettere minuscole (a-z), cifre (0-9) e trattini (-).", "webhooks.cannot_modify_system_webhook": "Non puoi modificare i messaggi di sistema.", + "webhooks.forum_target_conflict": "Non puoi specificare sia `thread_id` sia `thread_name`.", + "webhooks.forum_target_required": "I webhook che pubblicano in questo canale devono indicare un `thread_id` o un `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Hai raggiunto il limite di {count, plural, one {# webhook} other {# webhook}} per canale.", "webhooks.max_webhooks_per_guild_reached": "Hai raggiunto il limite di {count, plural, one {# webhook} other {# webhook}} per community.", + "webhooks.service_forum_unsupported": "Questo servizio di webhook non può pubblicare in un canale forum o multimediale senza un `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` e `applied_tags` funzionano solo nei canali forum e multimediali.", "webhooks.unknown_webhook": "Webhook sconosciuto.", "webhooks.webhook_name_length_invalid": "Il nome del webhook deve contenere da {min} a {max} caratteri." } diff --git a/packages/errors/src/i18n/weblate/locales/ja.json b/packages/errors/src/i18n/weblate/locales/ja.json index 5a02fba83..ce8bdee8b 100644 --- a/packages/errors/src/i18n/weblate/locales/ja.json +++ b/packages/errors/src/i18n/weblate/locales/ja.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "ウェブフック処理は利用できません。", "stripe.webhook_signature_invalid": "Stripeのウェブフック署名が無効です。", "stripe.webhook_signature_missing": "Stripeのウェブフック署名がありません。", + "threads.channel_has_threads": "スレッドがあるチャンネルはアナウンスチャンネルに変換できません。", + "threads.forum_tag_names_must_be_unique": "タグ名は重複できません。", + "threads.forum_tag_required": "このチャンネルに投稿するにはタグが必要です。", + "threads.hide_media_download_option_media_only": "メディアのダウンロードオプションを非表示にできるのはメディアチャンネルだけです。", + "threads.invalid_thread_notification_settings": "スレッドの通知設定が無効です。", + "threads.max_active_threads_reached": "このコミュニティはアクティブなスレッドの上限に達しました: {count}", + "threads.max_forum_tags_reached": "このチャンネルはタグの上限に達しました: {count}", + "threads.max_pinned_threads_reached": "このチャンネルはピン留めされた投稿の上限に達しました: {count}", + "threads.max_thread_members_reached": "このスレッドはメンバーの上限に達しました: {count}", + "threads.no_tags_available_to_non_moderators": "タグが必要ですが、このチャンネルでタグを付けられるのはモデレーターだけです。", + "threads.search_index_not_ready": "検索インデックスの準備がまだできていません。少し待ってからもう一度お試しください。", + "threads.thread_already_created_for_message": "このメッセージのスレッドはすでに作成されています。", + "threads.thread_archived": "このスレッドはアーカイブされています。", + "threads.thread_locked": "このスレッドはロックされています。", "tickets.ticket_already_completed": "このチケットはすでに完了しています。", "tos_and_age.date_of_birth_format_invalid": "生年月日の形式が無効です。", "tos_and_age.must_agree_to_tos_and_privacy": "利用規約とプライバシーポリシーに同意する必要があります。", "tos_and_age.must_be_minimum_age": "アカウントを作成するには{minAge, plural, other {#歳}}以上である必要があります。", "unknown_entities.unknown_channel": "チャンネルが見つかりませんでした。", + "unknown_entities.unknown_forum_tag": "タグが見つかりませんでした。", "unknown_entities.unknown_guild": "コミュニティが見つかりませんでした。", "unknown_entities.unknown_invite": "招待が見つからないか、無効になっています。", "unknown_entities.unknown_member": "このコミュニティにメンバーが見つかりませんでした。", "unknown_entities.unknown_message": "メッセージが見つかりませんでした。", "unknown_entities.unknown_role": "ロールが見つかりませんでした。", + "unknown_entities.unknown_thread_member": "スレッドのメンバーが見つかりませんでした。", "unknown_entities.unknown_user": "ユーザーが見つかりませんでした。", "unknown_entities.unknown_user_flag": "指定されたユーザーフラグは認識されません。", "urls.invalid_url_format": "URLの形式が無効です。", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "カスタムURLコードは{min}~{max}文字である必要があります。", "vanity_urls.vanity_url_invalid_characters": "カスタムURLには、小文字のラテン文字(a-z)、数字(0-9)、ハイフン(-)のみ使用できます。", "webhooks.cannot_modify_system_webhook": "システムメッセージは編集できません。", + "webhooks.forum_target_conflict": "`thread_id` と `thread_name` を同時に指定することはできません。", + "webhooks.forum_target_required": "このチャンネルに投稿するウェブフックには `thread_id` または `thread_name` が必要です。", "webhooks.max_webhooks_per_channel_reached": "このチャンネルでは、ウェブフックの上限{count, plural, other {#個}}に達しました。", "webhooks.max_webhooks_per_guild_reached": "このコミュニティでは、ウェブフックの上限{count, plural, other {#個}}に達しました。", + "webhooks.service_forum_unsupported": "このウェブフックサービスは `thread_id` なしではフォーラムチャンネルやメディアチャンネルに投稿できません。", + "webhooks.thread_name_requires_forum": "`thread_name` と `applied_tags` はフォーラムチャンネルとメディアチャンネルでのみ使えます。", "webhooks.unknown_webhook": "不明なウェブフックです。", "webhooks.webhook_name_length_invalid": "ウェブフック名は{min}~{max}文字である必要があります。" } diff --git a/packages/errors/src/i18n/weblate/locales/ko.json b/packages/errors/src/i18n/weblate/locales/ko.json index bfdf942d5..e4894d44e 100644 --- a/packages/errors/src/i18n/weblate/locales/ko.json +++ b/packages/errors/src/i18n/weblate/locales/ko.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "웹훅 처리가 불가능해요.", "stripe.webhook_signature_invalid": "Stripe 웹훅 서명이 유효하지 않아요.", "stripe.webhook_signature_missing": "Stripe 웹훅 서명이 누락되었어요.", + "threads.channel_has_threads": "스레드가 있는 채널은 공지 채널로 전환할 수 없어요.", + "threads.forum_tag_names_must_be_unique": "태그 이름은 중복될 수 없어요.", + "threads.forum_tag_required": "이 채널에 게시물을 만들려면 태그가 필요해요.", + "threads.hide_media_download_option_media_only": "미디어 다운로드 옵션 숨기기는 미디어 채널에서만 사용할 수 있어요.", + "threads.invalid_thread_notification_settings": "스레드 알림 설정이 올바르지 않아요.", + "threads.max_active_threads_reached": "이 커뮤니티가 활성 스레드 한도에 도달했어요: {count}", + "threads.max_forum_tags_reached": "이 채널이 태그 한도에 도달했어요: {count}", + "threads.max_pinned_threads_reached": "이 채널이 고정된 게시물 한도에 도달했어요: {count}", + "threads.max_thread_members_reached": "이 스레드가 멤버 한도에 도달했어요: {count}", + "threads.no_tags_available_to_non_moderators": "태그가 필요하지만 이 채널에서는 모더레이터만 태그를 달 수 있어요.", + "threads.search_index_not_ready": "검색 인덱스가 아직 준비되지 않았어요. 잠시 후 다시 시도해 주세요.", + "threads.thread_already_created_for_message": "이 메시지에 대한 스레드가 이미 만들어졌어요.", + "threads.thread_archived": "이 스레드는 보관되었어요.", + "threads.thread_locked": "이 스레드는 잠겨 있어요.", "tickets.ticket_already_completed": "이 티켓은 이미 처리되었어요.", "tos_and_age.date_of_birth_format_invalid": "생년월일 형식이 올바르지 않아요.", "tos_and_age.must_agree_to_tos_and_privacy": "이용약관과 개인정보처리방침에 동의해 주세요.", "tos_and_age.must_be_minimum_age": "계정을 만들려면 만 {minAge, plural, other {#세}} 이상이어야 해요.", "unknown_entities.unknown_channel": "채널을 찾을 수 없어요.", + "unknown_entities.unknown_forum_tag": "태그를 찾을 수 없어요.", "unknown_entities.unknown_guild": "커뮤니티를 찾을 수 없어요.", "unknown_entities.unknown_invite": "초대를 찾을 수 없거나 더 이상 유효하지 않아요.", "unknown_entities.unknown_member": "이 커뮤니티에서 멤버를 찾을 수 없어요.", "unknown_entities.unknown_message": "메시지를 찾을 수 없어요.", "unknown_entities.unknown_role": "역할을 찾을 수 없어요.", + "unknown_entities.unknown_thread_member": "스레드 멤버를 찾을 수 없어요.", "unknown_entities.unknown_user": "사용자를 찾을 수 없어요.", "unknown_entities.unknown_user_flag": "지정된 사용자 플래그를 인식하지 못했어요.", "urls.invalid_url_format": "URL 형식이 올바르지 않아요.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "맞춤 URL 코드는 {min}자에서 {max}자 사이여야 해요.", "vanity_urls.vanity_url_invalid_characters": "맞춤 URL은 소문자(a-z), 숫자(0-9), 하이픈(-)만 사용할 수 있어요.", "webhooks.cannot_modify_system_webhook": "시스템 메시지는 수정할 수 없어요.", + "webhooks.forum_target_conflict": "`thread_id`와 `thread_name`을 함께 지정할 수 없어요.", + "webhooks.forum_target_required": "이 채널에 게시하는 웹훅에는 `thread_id` 또는 `thread_name`이 필요해요.", "webhooks.max_webhooks_per_channel_reached": "채널당 최대 {count, plural, other {#개}}의 웹훅을 만들 수 있어요.", "webhooks.max_webhooks_per_guild_reached": "커뮤니티당 최대 {count, plural, other {#개}}의 웹훅을 만들 수 있어요.", + "webhooks.service_forum_unsupported": "이 웹훅 서비스는 `thread_id` 없이 포럼 채널이나 미디어 채널에 게시할 수 없어요.", + "webhooks.thread_name_requires_forum": "`thread_name`과 `applied_tags`는 포럼 채널과 미디어 채널에서만 작동해요.", "webhooks.unknown_webhook": "알 수 없는 웹훅이에요.", "webhooks.webhook_name_length_invalid": "웹훅 이름은 {min}자에서 {max}자 사이여야 해요." } diff --git a/packages/errors/src/i18n/weblate/locales/lt.json b/packages/errors/src/i18n/weblate/locales/lt.json index 239cf723a..63cf2fc76 100644 --- a/packages/errors/src/i18n/weblate/locales/lt.json +++ b/packages/errors/src/i18n/weblate/locales/lt.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhookų apdorojimas nepasiekiamas.", "stripe.webhook_signature_invalid": "Stripe webhooko parašas neteisingas.", "stripe.webhook_signature_missing": "Trūksta Stripe webhooko parašo.", + "threads.channel_has_threads": "Kanalų su gijomis negalima paversti pranešimų kanalais.", + "threads.forum_tag_names_must_be_unique": "Žymų pavadinimai turi būti unikalūs.", + "threads.forum_tag_required": "Norint sukurti įrašą šiame kanale, reikia žymos.", + "threads.hide_media_download_option_media_only": "Medijos atsisiuntimo parinktis galima paslėpti tik medijos kanaluose.", + "threads.invalid_thread_notification_settings": "Netinkami gijos pranešimų nustatymai.", + "threads.max_active_threads_reached": "Ši bendruomenė pasiekė aktyvių gijų ribą: {count}.", + "threads.max_forum_tags_reached": "Šis kanalas pasiekė žymų ribą: {count}.", + "threads.max_pinned_threads_reached": "Šis kanalas pasiekė prisegtų įrašų ribą: {count}.", + "threads.max_thread_members_reached": "Ši gija pasiekė narių ribą: {count}.", + "threads.no_tags_available_to_non_moderators": "Žyma privaloma, bet šiame kanale žymas gali pridėti tik moderatoriai.", + "threads.search_index_not_ready": "Paieškos indeksas dar neparuoštas. Pabandyk dar kartą po akimirkos.", + "threads.thread_already_created_for_message": "Šiai žinutei gija jau sukurta.", + "threads.thread_archived": "Ši gija suarchyvuota.", + "threads.thread_locked": "Ši gija užrakinta.", "tickets.ticket_already_completed": "Šis bilietas jau užbaigtas.", "tos_and_age.date_of_birth_format_invalid": "Netinkamas gimimo datos formatas.", "tos_and_age.must_agree_to_tos_and_privacy": "Privalai sutikti su paslaugų teikimo sąlygomis ir privatumo politika.", "tos_and_age.must_be_minimum_age": "Kad susikurtum paskyrą, turi būti bent {minAge, plural, one {# metų} few {# metų} many {# metų} other {# metų}}.", "unknown_entities.unknown_channel": "Kanalas nerastas.", + "unknown_entities.unknown_forum_tag": "Žyma nerasta.", "unknown_entities.unknown_guild": "Bendruomenė nerasta.", "unknown_entities.unknown_invite": "Kvietimas nerastas arba nebegalioja.", "unknown_entities.unknown_member": "Narys nerastas šioje bendruomenėje.", "unknown_entities.unknown_message": "Žinutė nerasta.", "unknown_entities.unknown_role": "Vaidmuo nerastas.", + "unknown_entities.unknown_thread_member": "Gijos narys nerastas.", "unknown_entities.unknown_user": "Naudotojas nerastas.", "unknown_entities.unknown_user_flag": "Nurodyta naudotojo vėliavėlė neatpažįstama.", "urls.invalid_url_format": "Netinkamas URL formatas.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Asmeninio URL kodas turi būti {min}–{max} simbolių.", "vanity_urls.vanity_url_invalid_characters": "Asmeniniame URL gali būti tik mažosios raidės (a-z), skaitmenys (0-9) ir brūkšneliai (-).", "webhooks.cannot_modify_system_webhook": "Negali redaguoti sistemos žinučių.", + "webhooks.forum_target_conflict": "Negali nurodyti ir `thread_id`, ir `thread_name`.", + "webhooks.forum_target_required": "Webhookams, kurie skelbia šiame kanale, reikia `thread_id` arba `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Pasiekei {count, plural, one {# webhooko} few {# webhookų} many {# webhooko} other {# webhookų}} ribą viename kanale.", "webhooks.max_webhooks_per_guild_reached": "Pasiekei {count, plural, one {# webhooko} few {# webhookų} many {# webhooko} other {# webhookų}} ribą vienoje bendruomenėje.", + "webhooks.service_forum_unsupported": "Ši webhookų paslauga negali skelbti forumo ar medijos kanale be `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` ir `applied_tags` veikia tik forumo ir medijos kanaluose.", "webhooks.unknown_webhook": "Nežinomas webhookas.", "webhooks.webhook_name_length_invalid": "Webhooko pavadinimas turi būti {min}–{max} simbolių." } diff --git a/packages/errors/src/i18n/weblate/locales/nl.json b/packages/errors/src/i18n/weblate/locales/nl.json index 9fcc3e60f..cb6f855a7 100644 --- a/packages/errors/src/i18n/weblate/locales/nl.json +++ b/packages/errors/src/i18n/weblate/locales/nl.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhookverwerking is niet beschikbaar.", "stripe.webhook_signature_invalid": "Stripe-webhookhandtekening is ongeldig.", "stripe.webhook_signature_missing": "Stripe-webhookhandtekening ontbreekt.", + "threads.channel_has_threads": "Kanalen met threads kunnen niet worden omgezet naar aankondigingskanalen.", + "threads.forum_tag_names_must_be_unique": "Tagnamen moeten uniek zijn.", + "threads.forum_tag_required": "Je hebt een tag nodig om een bericht in dit kanaal te plaatsen.", + "threads.hide_media_download_option_media_only": "Downloadopties voor media verbergen kan alleen in mediakanalen.", + "threads.invalid_thread_notification_settings": "Ongeldige meldingsinstellingen voor de thread.", + "threads.max_active_threads_reached": "Deze community heeft de limiet voor actieve threads bereikt: {count}.", + "threads.max_forum_tags_reached": "Dit kanaal heeft de taglimiet bereikt: {count}.", + "threads.max_pinned_threads_reached": "Dit kanaal heeft de limiet voor vastgezette berichten bereikt: {count}.", + "threads.max_thread_members_reached": "Deze thread heeft de ledenlimiet bereikt: {count}.", + "threads.no_tags_available_to_non_moderators": "Een tag is verplicht, maar in dit kanaal kunnen alleen moderators tags toevoegen.", + "threads.search_index_not_ready": "De zoekindex is nog niet klaar. Probeer het zo opnieuw.", + "threads.thread_already_created_for_message": "Er is al een thread aangemaakt voor dit bericht.", + "threads.thread_archived": "Deze thread is gearchiveerd.", + "threads.thread_locked": "Deze thread is vergrendeld.", "tickets.ticket_already_completed": "Dit ticket is al afgerond.", "tos_and_age.date_of_birth_format_invalid": "Ongeldig geboortedatumformaat.", "tos_and_age.must_agree_to_tos_and_privacy": "Je moet akkoord gaan met de Gebruiksvoorwaarden en het Privacybeleid.", "tos_and_age.must_be_minimum_age": "Je moet minstens {minAge, plural, one {# jaar} other {# jaar}} oud zijn om een account aan te maken.", "unknown_entities.unknown_channel": "Kanaal niet gevonden.", + "unknown_entities.unknown_forum_tag": "Tag niet gevonden.", "unknown_entities.unknown_guild": "Community niet gevonden.", "unknown_entities.unknown_invite": "De uitnodiging is niet gevonden of is niet meer geldig.", "unknown_entities.unknown_member": "Het lid is niet gevonden in deze community.", "unknown_entities.unknown_message": "Bericht niet gevonden.", "unknown_entities.unknown_role": "Rol niet gevonden.", + "unknown_entities.unknown_thread_member": "Threadlid niet gevonden.", "unknown_entities.unknown_user": "Gebruiker niet gevonden.", "unknown_entities.unknown_user_flag": "De opgegeven gebruikersvlag is niet herkend.", "urls.invalid_url_format": "Ongeldig URL-formaat.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "De code voor de aangepaste URL moet tussen {min} en {max} tekens lang zijn.", "vanity_urls.vanity_url_invalid_characters": "De aangepaste URL mag alleen kleine letters (a-z), cijfers (0-9) en koppeltekens (-) bevatten.", "webhooks.cannot_modify_system_webhook": "Systeemberichten kunnen niet worden bewerkt.", + "webhooks.forum_target_conflict": "Je kunt niet zowel `thread_id` als `thread_name` opgeven.", + "webhooks.forum_target_required": "Webhooks die in dit kanaal posten, hebben een `thread_id` of een `thread_name` nodig.", "webhooks.max_webhooks_per_channel_reached": "Je hebt het maximum van {count, plural, one {# webhook} other {# webhooks}} per kanaal bereikt.", "webhooks.max_webhooks_per_guild_reached": "Je hebt het maximum van {count, plural, one {# webhook} other {# webhooks}} per community bereikt.", + "webhooks.service_forum_unsupported": "Deze webhookdienst kan zonder `thread_id` niet posten in een forum- of mediakanaal.", + "webhooks.thread_name_requires_forum": "`thread_name` en `applied_tags` werken alleen in forum- en mediakanalen.", "webhooks.unknown_webhook": "Onbekende webhook.", "webhooks.webhook_name_length_invalid": "Webhooknaam moet tussen {min} en {max} tekens lang zijn." } diff --git a/packages/errors/src/i18n/weblate/locales/no.json b/packages/errors/src/i18n/weblate/locales/no.json index e49017c34..3ee6a1e9c 100644 --- a/packages/errors/src/i18n/weblate/locales/no.json +++ b/packages/errors/src/i18n/weblate/locales/no.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Behandling av webhooks er ikke tilgjengelig.", "stripe.webhook_signature_invalid": "Stripe-webhook-signaturen er ugyldig.", "stripe.webhook_signature_missing": "Stripe-webhook-signaturen mangler.", + "threads.channel_has_threads": "Kanaler med tråder kan ikke gjøres om til kunngjøringskanaler.", + "threads.forum_tag_names_must_be_unique": "Tagnavn må være unike.", + "threads.forum_tag_required": "Du trenger en tagg for å lage et innlegg i denne kanalen.", + "threads.hide_media_download_option_media_only": "Det er bare i mediekanaler at nedlastingsvalg for medier kan skjules.", + "threads.invalid_thread_notification_settings": "Ugyldige varslingsinnstillinger for tråden.", + "threads.max_active_threads_reached": "Dette fellesskapet har nådd grensen for aktive tråder: {count}.", + "threads.max_forum_tags_reached": "Denne kanalen har nådd grensen for tagger: {count}.", + "threads.max_pinned_threads_reached": "Denne kanalen har nådd grensen for festede innlegg: {count}.", + "threads.max_thread_members_reached": "Denne tråden har nådd grensen for medlemmer: {count}.", + "threads.no_tags_available_to_non_moderators": "Det kreves en tagg, men i denne kanalen er det bare moderatorer som kan legge til tagger.", + "threads.search_index_not_ready": "Søkeindeksen er ikke klar ennå. Prøv igjen om et øyeblikk.", + "threads.thread_already_created_for_message": "Det er allerede opprettet en tråd for denne meldingen.", + "threads.thread_archived": "Denne tråden er arkivert.", + "threads.thread_locked": "Denne tråden er låst.", "tickets.ticket_already_completed": "Denne saken er allerede fullført.", "tos_and_age.date_of_birth_format_invalid": "Ugyldig format for fødselsdato.", "tos_and_age.must_agree_to_tos_and_privacy": "Du må godta vilkårene for bruk og personvernreglene.", "tos_and_age.must_be_minimum_age": "Du må være minst {minAge, plural, one {# år} other {# år}} for å opprette en konto.", "unknown_entities.unknown_channel": "Kanalen ble ikke funnet.", + "unknown_entities.unknown_forum_tag": "Fant ikke taggen.", "unknown_entities.unknown_guild": "Fellesskapet ble ikke funnet.", "unknown_entities.unknown_invite": "Invitasjonen ble ikke funnet eller er ikke lenger gyldig.", "unknown_entities.unknown_member": "Medlemmet ble ikke funnet i dette fellesskapet.", "unknown_entities.unknown_message": "Meldingen ble ikke funnet.", "unknown_entities.unknown_role": "Rollen ble ikke funnet.", + "unknown_entities.unknown_thread_member": "Fant ikke trådmedlemmet.", "unknown_entities.unknown_user": "Brukeren ble ikke funnet.", "unknown_entities.unknown_user_flag": "Det angitte brukerflagget gjenkjennes ikke.", "urls.invalid_url_format": "Ugyldig URL-format.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Koden for den egendefinerte URL-en må inneholde mellom {min} og {max} tegn.", "vanity_urls.vanity_url_invalid_characters": "Den egendefinerte URL-en kan bare inneholde små bokstaver (a-z), sifre (0-9) og bindestreker (-).", "webhooks.cannot_modify_system_webhook": "Du kan ikke redigere systemmeldinger.", + "webhooks.forum_target_conflict": "Du kan ikke oppgi både `thread_id` og `thread_name`.", + "webhooks.forum_target_required": "Webhooks som poster i denne kanalen, må ha en `thread_id` eller et `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Du har nådd grensen på {count, plural, one {# webhook} other {# webhooks}} per kanal.", "webhooks.max_webhooks_per_guild_reached": "Du har nådd grensen på {count, plural, one {# webhook} other {# webhooks}} per fellesskap.", + "webhooks.service_forum_unsupported": "Denne webhooktjenesten kan ikke poste i en forum- eller mediekanal uten en `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` og `applied_tags` fungerer bare i forum- og mediekanaler.", "webhooks.unknown_webhook": "Ukjent webhook.", "webhooks.webhook_name_length_invalid": "Webhook-navnet må inneholde mellom {min} og {max} tegn." } diff --git a/packages/errors/src/i18n/weblate/locales/pl.json b/packages/errors/src/i18n/weblate/locales/pl.json index 961f87503..5ed772bbb 100644 --- a/packages/errors/src/i18n/weblate/locales/pl.json +++ b/packages/errors/src/i18n/weblate/locales/pl.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Przetwarzanie webhooków nie jest dostępne.", "stripe.webhook_signature_invalid": "Podpis webhooka Stripe jest nieprawidłowy.", "stripe.webhook_signature_missing": "Brak podpisu webhooka Stripe.", + "threads.channel_has_threads": "Kanałów z wątkami nie można przekształcić w kanały ogłoszeń.", + "threads.forum_tag_names_must_be_unique": "Nazwy tagów muszą być unikalne.", + "threads.forum_tag_required": "Aby utworzyć post na tym kanale, potrzebny jest tag.", + "threads.hide_media_download_option_media_only": "Ukrywanie opcji pobierania multimediów jest dostępne tylko na kanałach multimediów.", + "threads.invalid_thread_notification_settings": "Nieprawidłowe ustawienia powiadomień wątku.", + "threads.max_active_threads_reached": "Ta społeczność osiągnęła limit aktywnych wątków: {count}.", + "threads.max_forum_tags_reached": "Ten kanał osiągnął limit tagów: {count}.", + "threads.max_pinned_threads_reached": "Ten kanał osiągnął limit przypiętych postów: {count}.", + "threads.max_thread_members_reached": "Ten wątek osiągnął limit członków: {count}.", + "threads.no_tags_available_to_non_moderators": "Tag jest wymagany, ale na tym kanale tylko moderatorzy mogą dodawać tagi.", + "threads.search_index_not_ready": "Indeks wyszukiwania nie jest jeszcze gotowy. Spróbuj ponownie za chwilę.", + "threads.thread_already_created_for_message": "Dla tej wiadomości utworzono już wątek.", + "threads.thread_archived": "Ten wątek jest zarchiwizowany.", + "threads.thread_locked": "Ten wątek jest zablokowany.", "tickets.ticket_already_completed": "To zgłoszenie zostało już zakończone.", "tos_and_age.date_of_birth_format_invalid": "Nieprawidłowy format daty urodzenia.", "tos_and_age.must_agree_to_tos_and_privacy": "Musisz zaakceptować warunki korzystania z usługi oraz politykę prywatności.", "tos_and_age.must_be_minimum_age": "Musisz mieć co najmniej {minAge, plural, one {# rok} few {# lata} many {# lat} other {# roku}}, aby utworzyć konto.", "unknown_entities.unknown_channel": "Nie znaleziono kanału.", + "unknown_entities.unknown_forum_tag": "Nie znaleziono tagu.", "unknown_entities.unknown_guild": "Nie znaleziono społeczności.", "unknown_entities.unknown_invite": "Nie znaleziono zaproszenia lub jest już nieaktualne.", "unknown_entities.unknown_member": "Nie znaleziono członka w tej społeczności.", "unknown_entities.unknown_message": "Nie znaleziono wiadomości.", "unknown_entities.unknown_role": "Nie znaleziono roli.", + "unknown_entities.unknown_thread_member": "Nie znaleziono członka wątku.", "unknown_entities.unknown_user": "Nie znaleziono użytkownika.", "unknown_entities.unknown_user_flag": "Określona flaga użytkownika nie jest rozpoznawana.", "urls.invalid_url_format": "Nieprawidłowy format adresu URL.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Kod niestandardowego adresu URL musi mieć od {min} do {max} znaków.", "vanity_urls.vanity_url_invalid_characters": "Niestandardowy adres URL może zawierać tylko małe litery (a-z), cyfry (0-9) i łączniki (-).", "webhooks.cannot_modify_system_webhook": "Nie możesz edytować wiadomości systemowych.", + "webhooks.forum_target_conflict": "Nie możesz podać jednocześnie `thread_id` i `thread_name`.", + "webhooks.forum_target_required": "Webhooki publikujące na tym kanale muszą podać `thread_id` lub `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Osiągnięto limit {count, plural, one {# webhooka} few {# webhooków} many {# webhooków} other {# webhooka}} na kanał.", "webhooks.max_webhooks_per_guild_reached": "Osiągnięto limit {count, plural, one {# webhooka} few {# webhooków} many {# webhooków} other {# webhooka}} na społeczność.", + "webhooks.service_forum_unsupported": "Ta usługa webhooków nie może publikować na kanale forum ani multimediów bez `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` i `applied_tags` działają tylko na kanałach forum i multimediów.", "webhooks.unknown_webhook": "Nieznany webhook.", "webhooks.webhook_name_length_invalid": "Nazwa webhooka musi mieć od {min} do {max} znaków." } diff --git a/packages/errors/src/i18n/weblate/locales/pt-BR.json b/packages/errors/src/i18n/weblate/locales/pt-BR.json index c9390e645..168966b5a 100644 --- a/packages/errors/src/i18n/weblate/locales/pt-BR.json +++ b/packages/errors/src/i18n/weblate/locales/pt-BR.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "O processamento de webhook não está disponível.", "stripe.webhook_signature_invalid": "A assinatura do webhook do Stripe é inválida.", "stripe.webhook_signature_missing": "A assinatura do webhook do Stripe está ausente.", + "threads.channel_has_threads": "Canais com tópicos não podem ser convertidos em canais de anúncios.", + "threads.forum_tag_names_must_be_unique": "Os nomes das tags precisam ser únicos.", + "threads.forum_tag_required": "É preciso uma tag para criar uma publicação neste canal.", + "threads.hide_media_download_option_media_only": "Ocultar as opções de download de mídia só está disponível em canais de mídia.", + "threads.invalid_thread_notification_settings": "Configurações de notificação do tópico inválidas.", + "threads.max_active_threads_reached": "Esta comunidade atingiu o limite de tópicos ativos: {count}.", + "threads.max_forum_tags_reached": "Este canal atingiu o limite de tags: {count}.", + "threads.max_pinned_threads_reached": "Este canal atingiu o limite de publicações fixadas: {count}.", + "threads.max_thread_members_reached": "Este tópico atingiu o limite de membros: {count}.", + "threads.no_tags_available_to_non_moderators": "É preciso uma tag, mas só moderadores podem aplicar as tags deste canal.", + "threads.search_index_not_ready": "O índice de pesquisa ainda não está pronto. Tente de novo em instantes.", + "threads.thread_already_created_for_message": "Já foi criado um tópico para esta mensagem.", + "threads.thread_archived": "Este tópico está arquivado.", + "threads.thread_locked": "Este tópico está trancado.", "tickets.ticket_already_completed": "Este ticket já foi concluído.", "tos_and_age.date_of_birth_format_invalid": "Formato de data de nascimento inválido.", "tos_and_age.must_agree_to_tos_and_privacy": "Você precisa concordar com os Termos de serviço e a Política de privacidade.", "tos_and_age.must_be_minimum_age": "Você precisa ter pelo menos {minAge, plural, =1 {# ano} other {# anos}} para criar uma conta.", "unknown_entities.unknown_channel": "Canal não encontrado.", + "unknown_entities.unknown_forum_tag": "Tag não encontrada.", "unknown_entities.unknown_guild": "Comunidade não encontrada.", "unknown_entities.unknown_invite": "Convite não encontrado ou não é mais válido.", "unknown_entities.unknown_member": "Membro não encontrado nesta comunidade.", "unknown_entities.unknown_message": "Mensagem não encontrada.", "unknown_entities.unknown_role": "Cargo não encontrado.", + "unknown_entities.unknown_thread_member": "Membro do tópico não encontrado.", "unknown_entities.unknown_user": "Usuário não encontrado.", "unknown_entities.unknown_user_flag": "A flag de usuário especificada não é reconhecida.", "urls.invalid_url_format": "Formato de URL inválido.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "O código da URL personalizada deve ter entre {min} e {max} caracteres.", "vanity_urls.vanity_url_invalid_characters": "A URL personalizada só pode conter letras minúsculas (a-z), dígitos (0-9) e hífens (-).", "webhooks.cannot_modify_system_webhook": "Você não pode editar mensagens do sistema.", + "webhooks.forum_target_conflict": "Você não pode informar `thread_id` e `thread_name` ao mesmo tempo.", + "webhooks.forum_target_required": "Webhooks que publicam neste canal precisam de um `thread_id` ou de um `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Você atingiu o máximo de {count, plural, =1 {# webhook} other {# webhooks}} por canal.", "webhooks.max_webhooks_per_guild_reached": "Você atingiu o máximo de {count, plural, =1 {# webhook} other {# webhooks}} por comunidade.", + "webhooks.service_forum_unsupported": "Este serviço de webhook não consegue publicar em um canal de fórum ou de mídia sem um `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` e `applied_tags` só funcionam em canais de fórum e de mídia.", "webhooks.unknown_webhook": "Webhook desconhecido.", "webhooks.webhook_name_length_invalid": "O nome do webhook deve ter entre {min} e {max} caracteres." } diff --git a/packages/errors/src/i18n/weblate/locales/ro.json b/packages/errors/src/i18n/weblate/locales/ro.json index 443538fd5..046dd8b01 100644 --- a/packages/errors/src/i18n/weblate/locales/ro.json +++ b/packages/errors/src/i18n/weblate/locales/ro.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Procesarea webhook-urilor nu este disponibilă.", "stripe.webhook_signature_invalid": "Semnătura webhook Stripe este invalidă.", "stripe.webhook_signature_missing": "Lipsește semnătura webhook Stripe.", + "threads.channel_has_threads": "Canalele cu fire nu pot fi transformate în canale de anunțuri.", + "threads.forum_tag_names_must_be_unique": "Numele etichetelor trebuie să fie unice.", + "threads.forum_tag_required": "Pentru a crea o postare în acest canal este nevoie de o etichetă.", + "threads.hide_media_download_option_media_only": "Ascunderea opțiunilor de descărcare media este disponibilă doar în canalele media.", + "threads.invalid_thread_notification_settings": "Setări de notificare pentru fir nevalide.", + "threads.max_active_threads_reached": "Această comunitate a atins limita de fire active: {count}.", + "threads.max_forum_tags_reached": "Acest canal a atins limita de etichete: {count}.", + "threads.max_pinned_threads_reached": "Acest canal a atins limita de postări fixate: {count}.", + "threads.max_thread_members_reached": "Acest fir a atins limita de membri: {count}.", + "threads.no_tags_available_to_non_moderators": "Este nevoie de o etichetă, dar în acest canal doar moderatorii pot aplica etichete.", + "threads.search_index_not_ready": "Indexul de căutare nu este încă pregătit. Încearcă din nou peste câteva clipe.", + "threads.thread_already_created_for_message": "Pentru acest mesaj a fost deja creat un fir.", + "threads.thread_archived": "Acest fir este arhivat.", + "threads.thread_locked": "Acest fir este blocat.", "tickets.ticket_already_completed": "Acest tichet a fost deja finalizat.", "tos_and_age.date_of_birth_format_invalid": "Formatul datei de naștere este invalid.", "tos_and_age.must_agree_to_tos_and_privacy": "Trebuie să accepți Termenii și condițiile, precum și Politica de confidențialitate.", "tos_and_age.must_be_minimum_age": "Trebuie să ai cel puțin {minAge, plural, one {# an} few {# ani} other {# de ani}} pentru a-ți crea un cont.", "unknown_entities.unknown_channel": "Canalul nu a fost găsit.", + "unknown_entities.unknown_forum_tag": "Eticheta nu a fost găsită.", "unknown_entities.unknown_guild": "Comunitatea nu a fost găsită.", "unknown_entities.unknown_invite": "Invitația nu a fost găsită sau nu mai este validă.", "unknown_entities.unknown_member": "Membrul nu a fost găsit în această comunitate.", "unknown_entities.unknown_message": "Mesajul nu a fost găsit.", "unknown_entities.unknown_role": "Rolul nu a fost găsit.", + "unknown_entities.unknown_thread_member": "Membrul firului nu a fost găsit.", "unknown_entities.unknown_user": "Utilizatorul nu a fost găsit.", "unknown_entities.unknown_user_flag": "Indicatorul de utilizator specificat nu este recunoscut.", "urls.invalid_url_format": "Formatul URL-ului este invalid.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Codul URL personalizat trebuie să aibă între {min} și {max} caractere.", "vanity_urls.vanity_url_invalid_characters": "URL-ul personalizat poate conține doar litere mici (a-z), cifre (0-9) și liniuțe (-).", "webhooks.cannot_modify_system_webhook": "Nu poți edita mesajele de sistem.", + "webhooks.forum_target_conflict": "Nu poți specifica atât `thread_id`, cât și `thread_name`.", + "webhooks.forum_target_required": "Webhookurile care postează în acest canal au nevoie de `thread_id` sau `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Ai atins numărul maxim de {count, plural, one {# webhook} few {# webhook-uri} other {# de webhook-uri}} pe canal.", "webhooks.max_webhooks_per_guild_reached": "Ai atins numărul maxim de {count, plural, one {# webhook} few {# webhook-uri} other {# de webhook-uri}} pe comunitate.", + "webhooks.service_forum_unsupported": "Acest serviciu de webhook nu poate posta într-un canal de forum sau media fără `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` și `applied_tags` funcționează doar în canalele de forum și media.", "webhooks.unknown_webhook": "Webhook necunoscut.", "webhooks.webhook_name_length_invalid": "Numele webhook-ului trebuie să aibă între {min} și {max} caractere." } diff --git a/packages/errors/src/i18n/weblate/locales/ru.json b/packages/errors/src/i18n/weblate/locales/ru.json index 479008e6f..dc8950623 100644 --- a/packages/errors/src/i18n/weblate/locales/ru.json +++ b/packages/errors/src/i18n/weblate/locales/ru.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Обработка вебхуков недоступна.", "stripe.webhook_signature_invalid": "Недействительная подпись вебхука Stripe.", "stripe.webhook_signature_missing": "Отсутствует подпись вебхука Stripe.", + "threads.channel_has_threads": "Каналы с ветками нельзя преобразовать в каналы объявлений.", + "threads.forum_tag_names_must_be_unique": "Названия тегов должны быть уникальными.", + "threads.forum_tag_required": "Чтобы создать публикацию в этом канале, нужен тег.", + "threads.hide_media_download_option_media_only": "Скрыть параметры скачивания медиафайлов можно только в медиаканалах.", + "threads.invalid_thread_notification_settings": "Недопустимые настройки уведомлений ветки.", + "threads.max_active_threads_reached": "В этом сообществе достигнут лимит активных веток: {count}.", + "threads.max_forum_tags_reached": "В этом канале достигнут лимит тегов: {count}.", + "threads.max_pinned_threads_reached": "В этом канале достигнут лимит закреплённых публикаций: {count}.", + "threads.max_thread_members_reached": "В этой ветке достигнут лимит участников: {count}.", + "threads.no_tags_available_to_non_moderators": "Нужен тег, но в этом канале добавлять теги могут только модераторы.", + "threads.search_index_not_ready": "Поисковый индекс ещё не готов. Попробуй снова через минуту.", + "threads.thread_already_created_for_message": "Для этого сообщения уже создана ветка.", + "threads.thread_archived": "Эта ветка в архиве.", + "threads.thread_locked": "Эта ветка заблокирована.", "tickets.ticket_already_completed": "Этот тикет уже завершён.", "tos_and_age.date_of_birth_format_invalid": "Неверный формат даты рождения.", "tos_and_age.must_agree_to_tos_and_privacy": "Необходимо согласиться с Условиями использования и Политикой конфиденциальности.", "tos_and_age.must_be_minimum_age": "Для создания аккаунта тебе должно быть не меньше {minAge} {minAge, plural, one {года} few {лет} many {лет} other {лет}}.", "unknown_entities.unknown_channel": "Канал не найден.", + "unknown_entities.unknown_forum_tag": "Тег не найден.", "unknown_entities.unknown_guild": "Сообщество не найдено.", "unknown_entities.unknown_invite": "Приглашение не найдено или больше не действует.", "unknown_entities.unknown_member": "Участник не найден в этом сообществе.", "unknown_entities.unknown_message": "Сообщение не найдено.", "unknown_entities.unknown_role": "Роль не найдена.", + "unknown_entities.unknown_thread_member": "Участник ветки не найден.", "unknown_entities.unknown_user": "Пользователь не найден.", "unknown_entities.unknown_user_flag": "Указанный флаг пользователя не распознан.", "urls.invalid_url_format": "Неверный формат URL.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Код персонального URL должен содержать от {min} до {max} символов.", "vanity_urls.vanity_url_invalid_characters": "Персональный URL может содержать только строчные буквы (a-z), цифры (0-9) и дефисы (-).", "webhooks.cannot_modify_system_webhook": "Нельзя редактировать системные сообщения.", + "webhooks.forum_target_conflict": "Нельзя указать одновременно `thread_id` и `thread_name`.", + "webhooks.forum_target_required": "Вебхукам, которые публикуют в этот канал, нужен `thread_id` или `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Ты достиг максимума: {count} {count, plural, one {вебхук} few {вебхука} many {вебхуков} other {вебхука}} на канал.", "webhooks.max_webhooks_per_guild_reached": "Ты достиг максимума: {count} {count, plural, one {вебхук} few {вебхука} many {вебхуков} other {вебхука}} на сообщество.", + "webhooks.service_forum_unsupported": "Этот сервис вебхуков не может публиковать в канал форума или медиаканал без `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` и `applied_tags` работают только в каналах форума и медиаканалах.", "webhooks.unknown_webhook": "Неизвестный вебхук.", "webhooks.webhook_name_length_invalid": "Имя вебхука должно содержать от {min} до {max} символов." } diff --git a/packages/errors/src/i18n/weblate/locales/sv-SE.json b/packages/errors/src/i18n/weblate/locales/sv-SE.json index 10ca65645..ce06c0c79 100644 --- a/packages/errors/src/i18n/weblate/locales/sv-SE.json +++ b/packages/errors/src/i18n/weblate/locales/sv-SE.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhook-bearbetning är otillgänglig.", "stripe.webhook_signature_invalid": "Signaturen för webhooken från Stripe är ogiltig.", "stripe.webhook_signature_missing": "Signaturen för webhooken från Stripe saknas.", + "threads.channel_has_threads": "Kanaler med trådar kan inte göras om till meddelandekanaler.", + "threads.forum_tag_names_must_be_unique": "Taggnamn måste vara unika.", + "threads.forum_tag_required": "Det krävs en tagg för att skapa ett inlägg i den här kanalen.", + "threads.hide_media_download_option_media_only": "Att dölja nedladdningsalternativ för media går bara i mediekanaler.", + "threads.invalid_thread_notification_settings": "Ogiltiga aviseringsinställningar för tråden.", + "threads.max_active_threads_reached": "Den här communityn har nått sin gräns för aktiva trådar: {count}.", + "threads.max_forum_tags_reached": "Den här kanalen har nått sin gräns för taggar: {count}.", + "threads.max_pinned_threads_reached": "Den här kanalen har nått sin gräns för fästa inlägg: {count}.", + "threads.max_thread_members_reached": "Den här tråden har nått sin gräns för medlemmar: {count}.", + "threads.no_tags_available_to_non_moderators": "Det krävs en tagg, men i den här kanalen kan bara moderatorer sätta taggar.", + "threads.search_index_not_ready": "Sökindexet är inte klart än. Försök igen om en liten stund.", + "threads.thread_already_created_for_message": "Det har redan skapats en tråd för det här meddelandet.", + "threads.thread_archived": "Den här tråden är arkiverad.", + "threads.thread_locked": "Den här tråden är låst.", "tickets.ticket_already_completed": "Detta ärende är redan slutfört.", "tos_and_age.date_of_birth_format_invalid": "Ogiltigt format för födelsedatum.", "tos_and_age.must_agree_to_tos_and_privacy": "Du måste godkänna användarvillkoren och integritetspolicyn.", "tos_and_age.must_be_minimum_age": "Du måste vara minst {minAge, number} år gammal för att skapa ett konto.", "unknown_entities.unknown_channel": "Kanalen hittades inte.", + "unknown_entities.unknown_forum_tag": "Taggen hittades inte.", "unknown_entities.unknown_guild": "Communityn hittades inte.", "unknown_entities.unknown_invite": "Inbjudan hittades inte eller är inte längre giltig.", "unknown_entities.unknown_member": "Medlemmen hittades inte i den här communityn.", "unknown_entities.unknown_message": "Meddelandet hittades inte.", "unknown_entities.unknown_role": "Rollen hittades inte.", + "unknown_entities.unknown_thread_member": "Trådmedlemmen hittades inte.", "unknown_entities.unknown_user": "Användaren hittades inte.", "unknown_entities.unknown_user_flag": "Den angivna användarflaggan känns inte igen.", "urls.invalid_url_format": "Ogiltigt URL-format.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Koden för den anpassade URL:en måste vara mellan {min} och {max} tecken lång.", "vanity_urls.vanity_url_invalid_characters": "En anpassad URL får endast innehålla gemener (a-z), siffror (0-9) och bindestreck (-).", "webhooks.cannot_modify_system_webhook": "Du kan inte ändra systemmeddelanden.", + "webhooks.forum_target_conflict": "Du kan inte ange både `thread_id` och `thread_name`.", + "webhooks.forum_target_required": "Webhooks som postar i den här kanalen behöver ett `thread_id` eller ett `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Du har nått gränsen på {count, plural, one {# webhook} other {# webhooks}} per kanal.", "webhooks.max_webhooks_per_guild_reached": "Du har nått gränsen på {count, plural, one {# webhook} other {# webhooks}} per community.", + "webhooks.service_forum_unsupported": "Den här webhooktjänsten kan inte posta i en forum- eller mediekanal utan ett `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` och `applied_tags` fungerar bara i forum- och mediekanaler.", "webhooks.unknown_webhook": "Okänd webhook.", "webhooks.webhook_name_length_invalid": "Webhooknamnet måste vara mellan {min} och {max} tecken långt." } diff --git a/packages/errors/src/i18n/weblate/locales/th.json b/packages/errors/src/i18n/weblate/locales/th.json index 2afbbd321..c5b998bae 100644 --- a/packages/errors/src/i18n/weblate/locales/th.json +++ b/packages/errors/src/i18n/weblate/locales/th.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "การประมวลผล webhook ไม่พร้อมใช้งาน", "stripe.webhook_signature_invalid": "ลายเซ็น webhook Stripe ไม่ถูกต้อง", "stripe.webhook_signature_missing": "ไม่พบลายเซ็น webhook Stripe", + "threads.channel_has_threads": "ไม่สามารถแปลงช่องที่มีเธรดเป็นช่องประกาศได้", + "threads.forum_tag_names_must_be_unique": "ชื่อแท็กต้องไม่ซ้ำกัน", + "threads.forum_tag_required": "ต้องมีแท็กจึงจะสร้างโพสต์ในช่องนี้ได้", + "threads.hide_media_download_option_media_only": "การซ่อนตัวเลือกดาวน์โหลดสื่อใช้ได้เฉพาะในช่องสื่อ", + "threads.invalid_thread_notification_settings": "การตั้งค่าการแจ้งเตือนของเธรดไม่ถูกต้อง", + "threads.max_active_threads_reached": "คอมมูนิตี้นี้มีเธรดที่ใช้งานอยู่ครบขีดจำกัดแล้ว: {count}", + "threads.max_forum_tags_reached": "ช่องนี้มีแท็กครบขีดจำกัดแล้ว: {count}", + "threads.max_pinned_threads_reached": "ช่องนี้มีโพสต์ที่ปักหมุดครบขีดจำกัดแล้ว: {count}", + "threads.max_thread_members_reached": "เธรดนี้มีสมาชิกครบขีดจำกัดแล้ว: {count}", + "threads.no_tags_available_to_non_moderators": "ต้องมีแท็ก แต่ในช่องนี้มีเพียงผู้ดูแลเท่านั้นที่ใส่แท็กได้", + "threads.search_index_not_ready": "ดัชนีการค้นหายังไม่พร้อม ลองใหม่อีกครั้งในอีกสักครู่", + "threads.thread_already_created_for_message": "มีการสร้างเธรดสำหรับข้อความนี้แล้ว", + "threads.thread_archived": "เธรดนี้ถูกเก็บถาวรแล้ว", + "threads.thread_locked": "เธรดนี้ถูกล็อกอยู่", "tickets.ticket_already_completed": "ตั๋วนี้ดำเนินการเสร็จสิ้นแล้ว", "tos_and_age.date_of_birth_format_invalid": "รูปแบบวันเกิดไม่ถูกต้อง", "tos_and_age.must_agree_to_tos_and_privacy": "คุณต้องยอมรับข้อกำหนดในการให้บริการ และนโยบายความเป็นส่วนตัว", "tos_and_age.must_be_minimum_age": "คุณต้องมีอายุอย่างน้อย {minAge} {minAge, plural, other {ปี}} จึงจะสร้างบัญชีได้", "unknown_entities.unknown_channel": "ไม่พบช่อง", + "unknown_entities.unknown_forum_tag": "ไม่พบแท็ก", "unknown_entities.unknown_guild": "ไม่พบคอมมูนิตี้", "unknown_entities.unknown_invite": "ไม่พบคำเชิญ หรือคำเชิญใช้งานไม่ได้แล้ว", "unknown_entities.unknown_member": "ไม่พบสมาชิกในคอมมูนิตี้นี้", "unknown_entities.unknown_message": "ไม่พบข้อความ", "unknown_entities.unknown_role": "ไม่พบบทบาท", + "unknown_entities.unknown_thread_member": "ไม่พบสมาชิกของเธรด", "unknown_entities.unknown_user": "ไม่พบผู้ใช้", "unknown_entities.unknown_user_flag": "ไม่รู้จักแฟล็กผู้ใช้ที่ระบุ", "urls.invalid_url_format": "รูปแบบ URL ไม่ถูกต้อง", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "รหัส Vanity URL ต้องมีความยาวระหว่าง {min} ถึง {max} ตัวอักษร", "vanity_urls.vanity_url_invalid_characters": "Vanity URL สามารถมีได้เฉพาะตัวอักษรพิมพ์เล็ก (a-z), ตัวเลข (0-9) และเครื่องหมายขีดกลาง (-)", "webhooks.cannot_modify_system_webhook": "คุณไม่สามารถแก้ไขข้อความระบบได้", + "webhooks.forum_target_conflict": "คุณระบุ `thread_id` และ `thread_name` พร้อมกันไม่ได้", + "webhooks.forum_target_required": "webhook ที่โพสต์ในช่องนี้ต้องมี `thread_id` หรือ `thread_name`", "webhooks.max_webhooks_per_channel_reached": "คุณมี webhook ครบ {count} {count, plural, other {รายการ}} ต่อช่องแล้ว", "webhooks.max_webhooks_per_guild_reached": "คุณมี webhook ครบ {count} {count, plural, other {รายการ}} ต่อคอมมูนิตี้แล้ว", + "webhooks.service_forum_unsupported": "บริการ webhook นี้โพสต์ในช่องฟอรัมหรือช่องสื่อโดยไม่มี `thread_id` ไม่ได้", + "webhooks.thread_name_requires_forum": "`thread_name` และ `applied_tags` ใช้ได้เฉพาะในช่องฟอรัมและช่องสื่อ", "webhooks.unknown_webhook": "ไม่พบ webhook", "webhooks.webhook_name_length_invalid": "ชื่อ webhook ต้องมีความยาวระหว่าง {min} ถึง {max} ตัวอักษร" } diff --git a/packages/errors/src/i18n/weblate/locales/tr.json b/packages/errors/src/i18n/weblate/locales/tr.json index 2f4d13b70..b78a9fd85 100644 --- a/packages/errors/src/i18n/weblate/locales/tr.json +++ b/packages/errors/src/i18n/weblate/locales/tr.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhook işleme kullanılamıyor.", "stripe.webhook_signature_invalid": "Stripe webhook imzası geçersiz.", "stripe.webhook_signature_missing": "Stripe webhook imzası eksik.", + "threads.channel_has_threads": "Alt başlık içeren kanallar duyuru kanalına dönüştürülemez.", + "threads.forum_tag_names_must_be_unique": "Etiket adları benzersiz olmalıdır.", + "threads.forum_tag_required": "Bu kanalda gönderi oluşturmak için bir etiket gerekir.", + "threads.hide_media_download_option_media_only": "Medya indirme seçeneklerini gizleme yalnızca medya kanallarında kullanılabilir.", + "threads.invalid_thread_notification_settings": "Geçersiz alt başlık bildirim ayarları.", + "threads.max_active_threads_reached": "Bu topluluk aktif alt başlık sınırına ulaştı: {count}.", + "threads.max_forum_tags_reached": "Bu kanal etiket sınırına ulaştı: {count}.", + "threads.max_pinned_threads_reached": "Bu kanal sabitlenmiş gönderi sınırına ulaştı: {count}.", + "threads.max_thread_members_reached": "Bu alt başlık üye sınırına ulaştı: {count}.", + "threads.no_tags_available_to_non_moderators": "Etiket gerekli, ancak bu kanalda etiketleri yalnızca moderatörler ekleyebilir.", + "threads.search_index_not_ready": "Arama dizini henüz hazır değil. Birazdan tekrar dene.", + "threads.thread_already_created_for_message": "Bu mesaj için zaten bir alt başlık oluşturuldu.", + "threads.thread_archived": "Bu alt başlık arşivlendi.", + "threads.thread_locked": "Bu alt başlık kilitli.", "tickets.ticket_already_completed": "Bu bilet zaten tamamlandı.", "tos_and_age.date_of_birth_format_invalid": "Geçersiz doğum tarihi biçimi.", "tos_and_age.must_agree_to_tos_and_privacy": "Hizmet Şartlarını ve Gizlilik Politikasını kabul etmelisin.", "tos_and_age.must_be_minimum_age": "Hesap oluşturmak için en az {minAge, plural, one {# yaşında} other {# yaşında}} olmalısın.", "unknown_entities.unknown_channel": "Kanal bulunamadı.", + "unknown_entities.unknown_forum_tag": "Etiket bulunamadı.", "unknown_entities.unknown_guild": "Topluluk bulunamadı.", "unknown_entities.unknown_invite": "Davet bulunamadı veya artık geçerli değil.", "unknown_entities.unknown_member": "Bu toplulukta üye bulunamadı.", "unknown_entities.unknown_message": "Mesaj bulunamadı.", "unknown_entities.unknown_role": "Rol bulunamadı.", + "unknown_entities.unknown_thread_member": "Alt başlık üyesi bulunamadı.", "unknown_entities.unknown_user": "Kullanıcı bulunamadı.", "unknown_entities.unknown_user_flag": "Belirtilen kullanıcı bayrağı tanınmıyor.", "urls.invalid_url_format": "Geçersiz URL biçimi.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Özel URL kodu {min} ile {max} karakter arasında olmalı.", "vanity_urls.vanity_url_invalid_characters": "Özel URL yalnızca küçük harfler (a-z), rakamlar (0-9) ve kısa çizgiler (-) içerebilir.", "webhooks.cannot_modify_system_webhook": "Sistem mesajlarını düzenleyemezsin.", + "webhooks.forum_target_conflict": "`thread_id` ve `thread_name` alanlarını birlikte belirtemezsin.", + "webhooks.forum_target_required": "Bu kanala gönderi yapan webhook'lar için `thread_id` veya `thread_name` gerekir.", "webhooks.max_webhooks_per_channel_reached": "Kanal başına en fazla {count, plural, one {# webhook} other {# webhook}} sınırına ulaştın.", "webhooks.max_webhooks_per_guild_reached": "Topluluk başına en fazla {count, plural, one {# webhook} other {# webhook}} sınırına ulaştın.", + "webhooks.service_forum_unsupported": "Bu webhook hizmeti `thread_id` olmadan forum veya medya kanalına gönderi yapamaz.", + "webhooks.thread_name_requires_forum": "`thread_name` ve `applied_tags` yalnızca forum ve medya kanallarında çalışır.", "webhooks.unknown_webhook": "Bilinmeyen webhook.", "webhooks.webhook_name_length_invalid": "Webhook adı {min} ile {max} karakter arasında olmalı." } diff --git a/packages/errors/src/i18n/weblate/locales/uk.json b/packages/errors/src/i18n/weblate/locales/uk.json index 6d93cd521..57f4e0299 100644 --- a/packages/errors/src/i18n/weblate/locales/uk.json +++ b/packages/errors/src/i18n/weblate/locales/uk.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Обробка вебхуків недоступна.", "stripe.webhook_signature_invalid": "Недійсний підпис вебхука Stripe.", "stripe.webhook_signature_missing": "Відсутній підпис вебхука Stripe.", + "threads.channel_has_threads": "Канали з гілками не можна перетворити на канали оголошень.", + "threads.forum_tag_names_must_be_unique": "Назви тегів мають бути унікальними.", + "threads.forum_tag_required": "Щоб створити допис у цьому каналі, потрібен тег.", + "threads.hide_media_download_option_media_only": "Приховати параметри завантаження медіафайлів можна лише в медіаканалах.", + "threads.invalid_thread_notification_settings": "Недійсні налаштування сповіщень гілки.", + "threads.max_active_threads_reached": "У цій спільноті досягнуто ліміту активних гілок: {count}.", + "threads.max_forum_tags_reached": "У цьому каналі досягнуто ліміту тегів: {count}.", + "threads.max_pinned_threads_reached": "У цьому каналі досягнуто ліміту закріплених дописів: {count}.", + "threads.max_thread_members_reached": "У цій гілці досягнуто ліміту учасників: {count}.", + "threads.no_tags_available_to_non_moderators": "Потрібен тег, але в цьому каналі додавати теги можуть лише модератори.", + "threads.search_index_not_ready": "Пошуковий індекс ще не готовий. Спробуй ще раз за мить.", + "threads.thread_already_created_for_message": "Для цього повідомлення вже створено гілку.", + "threads.thread_archived": "Ця гілка в архіві.", + "threads.thread_locked": "Ця гілка заблокована.", "tickets.ticket_already_completed": "Цей тікет уже завершено.", "tos_and_age.date_of_birth_format_invalid": "Недійсний формат дати народження.", "tos_and_age.must_agree_to_tos_and_privacy": "Ти маєш погодитися з Умовами використання і Політикою конфіденційності.", "tos_and_age.must_be_minimum_age": "Тобі має бути щонайменше {minAge} {minAge, plural, one {рік} few {роки} many {років} other {року}}, щоб створити акаунт.", "unknown_entities.unknown_channel": "Канал не знайдено.", + "unknown_entities.unknown_forum_tag": "Тег не знайдено.", "unknown_entities.unknown_guild": "Спільноту не знайдено.", "unknown_entities.unknown_invite": "Запрошення не знайдено або воно вже недійсне.", "unknown_entities.unknown_member": "Учасника не знайдено в цій спільноті.", "unknown_entities.unknown_message": "Повідомлення не знайдено.", "unknown_entities.unknown_role": "Роль не знайдено.", + "unknown_entities.unknown_thread_member": "Учасника гілки не знайдено.", "unknown_entities.unknown_user": "Користувача не знайдено.", "unknown_entities.unknown_user_flag": "Зазначений прапорець користувача не розпізнано.", "urls.invalid_url_format": "Неправильний формат URL.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Код персоналізованої URL-адреси має містити від {min} до {max} символів.", "vanity_urls.vanity_url_invalid_characters": "Персоналізована URL-адреса може містити лише малі літери (a-z), цифри (0-9) та дефіси (-).", "webhooks.cannot_modify_system_webhook": "Системні повідомлення не можна редагувати.", + "webhooks.forum_target_conflict": "Не можна вказати водночас `thread_id` і `thread_name`.", + "webhooks.forum_target_required": "Вебхукам, які публікують у цей канал, потрібен `thread_id` або `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Досягнуто максимуму: {count} {count, plural, one {вебхук} few {вебхуки} many {вебхуків} other {вебхука}} на канал.", "webhooks.max_webhooks_per_guild_reached": "Досягнуто максимуму: {count} {count, plural, one {вебхук} few {вебхуки} many {вебхуків} other {вебхука}} на спільноту.", + "webhooks.service_forum_unsupported": "Цей сервіс вебхуків не може публікувати в канал форуму чи медіаканал без `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` і `applied_tags` працюють лише в каналах форуму та медіаканалах.", "webhooks.unknown_webhook": "Невідомий вебхук.", "webhooks.webhook_name_length_invalid": "Назва вебхука має містити від {min} до {max} символів." } diff --git a/packages/errors/src/i18n/weblate/locales/vi.json b/packages/errors/src/i18n/weblate/locales/vi.json index fb21248e5..223d8799c 100644 --- a/packages/errors/src/i18n/weblate/locales/vi.json +++ b/packages/errors/src/i18n/weblate/locales/vi.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Xử lý webhook không khả dụng.", "stripe.webhook_signature_invalid": "Chữ ký số webhook Stripe không hợp lệ.", "stripe.webhook_signature_missing": "Thiếu chữ ký số webhook Stripe.", + "threads.channel_has_threads": "Không thể chuyển kênh có chủ đề thành kênh thông báo.", + "threads.forum_tag_names_must_be_unique": "Tên thẻ phải là duy nhất.", + "threads.forum_tag_required": "Cần có thẻ để tạo bài đăng trong kênh này.", + "threads.hide_media_download_option_media_only": "Chỉ có thể ẩn tùy chọn tải xuống phương tiện trong kênh phương tiện.", + "threads.invalid_thread_notification_settings": "Cài đặt thông báo của chủ đề không hợp lệ.", + "threads.max_active_threads_reached": "Cộng đồng này đã đạt giới hạn chủ đề đang hoạt động: {count}.", + "threads.max_forum_tags_reached": "Kênh này đã đạt giới hạn thẻ: {count}.", + "threads.max_pinned_threads_reached": "Kênh này đã đạt giới hạn bài đăng được ghim: {count}.", + "threads.max_thread_members_reached": "Chủ đề này đã đạt giới hạn thành viên: {count}.", + "threads.no_tags_available_to_non_moderators": "Cần có thẻ, nhưng trong kênh này chỉ người kiểm duyệt mới có thể gắn thẻ.", + "threads.search_index_not_ready": "Chỉ mục tìm kiếm chưa sẵn sàng. Hãy thử lại sau giây lát.", + "threads.thread_already_created_for_message": "Đã có chủ đề được tạo cho tin nhắn này.", + "threads.thread_archived": "Chủ đề này đã được lưu trữ.", + "threads.thread_locked": "Chủ đề này đã bị khóa.", "tickets.ticket_already_completed": "Vé này đã được hoàn tất.", "tos_and_age.date_of_birth_format_invalid": "Định dạng ngày sinh không hợp lệ.", "tos_and_age.must_agree_to_tos_and_privacy": "Bạn phải đồng ý với Điều khoản dịch vụ và Chính sách bảo mật.", "tos_and_age.must_be_minimum_age": "Bạn phải từ {minAge} {minAge, plural, other {tuổi}} trở lên để tạo tài khoản.", "unknown_entities.unknown_channel": "Không tìm thấy kênh này.", + "unknown_entities.unknown_forum_tag": "Không tìm thấy thẻ.", "unknown_entities.unknown_guild": "Không tìm thấy cộng đồng này.", "unknown_entities.unknown_invite": "Không tìm thấy lời mời này hoặc lời mời không còn hiệu lực.", "unknown_entities.unknown_member": "Không tìm thấy thành viên này trong cộng đồng.", "unknown_entities.unknown_message": "Không tìm thấy tin nhắn này.", "unknown_entities.unknown_role": "Không tìm thấy vai trò này.", + "unknown_entities.unknown_thread_member": "Không tìm thấy thành viên của chủ đề.", "unknown_entities.unknown_user": "Không tìm thấy người dùng này.", "unknown_entities.unknown_user_flag": "Không nhận dạng được flag người dùng đã chỉ định.", "urls.invalid_url_format": "Định dạng URL không hợp lệ.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Mã URL tùy chỉnh phải có độ dài từ {min} đến {max} ký tự.", "vanity_urls.vanity_url_invalid_characters": "URL tùy chỉnh chỉ có thể chứa chữ cái thường (a-z), chữ số (0-9) và dấu gạch nối (-).", "webhooks.cannot_modify_system_webhook": "Bạn không thể chỉnh sửa tin nhắn hệ thống.", + "webhooks.forum_target_conflict": "Bạn không thể chỉ định cả `thread_id` và `thread_name`.", + "webhooks.forum_target_required": "Webhook đăng vào kênh này cần có `thread_id` hoặc `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "Bạn đã đạt tối đa {count} {count, plural, other {webhook}} cho mỗi kênh.", "webhooks.max_webhooks_per_guild_reached": "Bạn đã đạt tối đa {count} {count, plural, other {webhook}} cho mỗi cộng đồng.", + "webhooks.service_forum_unsupported": "Dịch vụ webhook này không thể đăng vào kênh diễn đàn hoặc kênh phương tiện khi không có `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` và `applied_tags` chỉ hoạt động trong kênh diễn đàn và kênh phương tiện.", "webhooks.unknown_webhook": "Webhook không xác định.", "webhooks.webhook_name_length_invalid": "Tên webhook phải có độ dài từ {min} đến {max} ký tự." } diff --git a/packages/errors/src/i18n/weblate/locales/zh-CN.json b/packages/errors/src/i18n/weblate/locales/zh-CN.json index f98b36b9d..e9028c4f6 100644 --- a/packages/errors/src/i18n/weblate/locales/zh-CN.json +++ b/packages/errors/src/i18n/weblate/locales/zh-CN.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhook 处理不可用。", "stripe.webhook_signature_invalid": "Stripe webhook 签名无效。", "stripe.webhook_signature_missing": "缺少 Stripe webhook 签名。", + "threads.channel_has_threads": "含有子区的频道无法转换为公告频道。", + "threads.forum_tag_names_must_be_unique": "标签名称不能重复。", + "threads.forum_tag_required": "在此频道发帖需要选择一个标签。", + "threads.hide_media_download_option_media_only": "只有媒体频道才能隐藏媒体下载选项。", + "threads.invalid_thread_notification_settings": "子区通知设置无效。", + "threads.max_active_threads_reached": "此社区的活跃子区已达上限:{count}。", + "threads.max_forum_tags_reached": "此频道的标签已达上限:{count}。", + "threads.max_pinned_threads_reached": "此频道的置顶帖子已达上限:{count}。", + "threads.max_thread_members_reached": "此子区的成员已达上限:{count}。", + "threads.no_tags_available_to_non_moderators": "需要选择标签,但在此频道中只有版主可以添加标签。", + "threads.search_index_not_ready": "搜索索引尚未就绪,请稍后再试。", + "threads.thread_already_created_for_message": "已为此消息创建过子区。", + "threads.thread_archived": "此子区已归档。", + "threads.thread_locked": "此子区已锁定。", "tickets.ticket_already_completed": "此工单已完成。", "tos_and_age.date_of_birth_format_invalid": "出生日期格式无效。", "tos_and_age.must_agree_to_tos_and_privacy": "你必须同意服务条款和隐私政策。", "tos_and_age.must_be_minimum_age": "你必须年满 {minAge, plural, other {# 岁}}才能注册账号。", "unknown_entities.unknown_channel": "未找到频道。", + "unknown_entities.unknown_forum_tag": "未找到标签。", "unknown_entities.unknown_guild": "未找到社区。", "unknown_entities.unknown_invite": "未找到邀请或邀请已失效。", "unknown_entities.unknown_member": "未在此社区中找到成员。", "unknown_entities.unknown_message": "未找到消息。", "unknown_entities.unknown_role": "未找到身份组。", + "unknown_entities.unknown_thread_member": "未找到子区成员。", "unknown_entities.unknown_user": "未找到用户。", "unknown_entities.unknown_user_flag": "无法识别指定的用户标记。", "urls.invalid_url_format": "URL 格式无效。", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "自定义 URL 代码长度必须在 {min} 到 {max} 个字符之间。", "vanity_urls.vanity_url_invalid_characters": "自定义 URL 只能包含小写字母(a-z)、数字(0-9)和连字符(-)。", "webhooks.cannot_modify_system_webhook": "你无法编辑系统消息。", + "webhooks.forum_target_conflict": "不能同时指定 `thread_id` 和 `thread_name`。", + "webhooks.forum_target_required": "在此频道发帖的 webhook 需要提供 `thread_id` 或 `thread_name`。", "webhooks.max_webhooks_per_channel_reached": "你已达到每个频道 {count, plural, other {# 个 webhook}}的上限。", "webhooks.max_webhooks_per_guild_reached": "你已达到每个社区 {count, plural, other {# 个 webhook}}的上限。", + "webhooks.service_forum_unsupported": "此 webhook 服务在没有 `thread_id` 的情况下无法在论坛频道或媒体频道中发帖。", + "webhooks.thread_name_requires_forum": "`thread_name` 和 `applied_tags` 仅适用于论坛频道和媒体频道。", "webhooks.unknown_webhook": "未知 webhook。", "webhooks.webhook_name_length_invalid": "Webhook 名称长度必须在 {min} 到 {max} 个字符之间。" } diff --git a/packages/errors/src/i18n/weblate/locales/zh-TW.json b/packages/errors/src/i18n/weblate/locales/zh-TW.json index 241024675..1c1c527a6 100644 --- a/packages/errors/src/i18n/weblate/locales/zh-TW.json +++ b/packages/errors/src/i18n/weblate/locales/zh-TW.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhook 處理功能無法使用。", "stripe.webhook_signature_invalid": "Stripe Webhook 簽名無效。", "stripe.webhook_signature_missing": "缺少 Stripe Webhook 簽名。", + "threads.channel_has_threads": "含有討論串的頻道無法轉換為公告頻道。", + "threads.forum_tag_names_must_be_unique": "標籤名稱不能重複。", + "threads.forum_tag_required": "在此頻道發文需要選擇一個標籤。", + "threads.hide_media_download_option_media_only": "只有媒體頻道才能隱藏媒體下載選項。", + "threads.invalid_thread_notification_settings": "討論串通知設定無效。", + "threads.max_active_threads_reached": "此社群的活躍討論串已達上限:{count}。", + "threads.max_forum_tags_reached": "此頻道的標籤已達上限:{count}。", + "threads.max_pinned_threads_reached": "此頻道的釘選貼文已達上限:{count}。", + "threads.max_thread_members_reached": "此討論串的成員已達上限:{count}。", + "threads.no_tags_available_to_non_moderators": "需要選擇標籤,但在此頻道中只有管理員可以新增標籤。", + "threads.search_index_not_ready": "搜尋索引尚未就緒,請稍後再試。", + "threads.thread_already_created_for_message": "已為此訊息建立過討論串。", + "threads.thread_archived": "此討論串已封存。", + "threads.thread_locked": "此討論串已鎖定。", "tickets.ticket_already_completed": "此票證已完成。", "tos_and_age.date_of_birth_format_invalid": "出生日期格式無效。", "tos_and_age.must_agree_to_tos_and_privacy": "你必須同意服務條款與隱私權政策。", "tos_and_age.must_be_minimum_age": "你必須至少 {minAge, plural, other {# 歲}}才能建立帳號。", "unknown_entities.unknown_channel": "找不到頻道。", + "unknown_entities.unknown_forum_tag": "找不到標籤。", "unknown_entities.unknown_guild": "找不到社群。", "unknown_entities.unknown_invite": "找不到邀請,或邀請已失效。", "unknown_entities.unknown_member": "在此社群中找不到成員。", "unknown_entities.unknown_message": "找不到訊息。", "unknown_entities.unknown_role": "找不到身分組。", + "unknown_entities.unknown_thread_member": "找不到討論串成員。", "unknown_entities.unknown_user": "找不到使用者。", "unknown_entities.unknown_user_flag": "無法辨識指定的使用者標誌。", "urls.invalid_url_format": "無效的 URL 格式。", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "自訂網址代碼必須介於 {min} 到 {max} 個字元之間。", "vanity_urls.vanity_url_invalid_characters": "自訂網址只能包含小寫字母 (a-z)、數字 (0-9) 和連字號 (-)。", "webhooks.cannot_modify_system_webhook": "你無法編輯系統訊息。", + "webhooks.forum_target_conflict": "無法同時指定 `thread_id` 和 `thread_name`。", + "webhooks.forum_target_required": "在此頻道發文的 Webhook 需要提供 `thread_id` 或 `thread_name`。", "webhooks.max_webhooks_per_channel_reached": "你已達到每個頻道最多 {count, plural, other {# 個}} Webhook 的上限。", "webhooks.max_webhooks_per_guild_reached": "你已達到每個社群最多 {count, plural, other {# 個}} Webhook 的上限。", + "webhooks.service_forum_unsupported": "此 Webhook 服務在沒有 `thread_id` 的情況下無法在論壇頻道或媒體頻道中發文。", + "webhooks.thread_name_requires_forum": "`thread_name` 和 `applied_tags` 僅適用於論壇頻道和媒體頻道。", "webhooks.unknown_webhook": "未知的 Webhook。", "webhooks.webhook_name_length_invalid": "Webhook 名稱必須介於 {min} 到 {max} 個字元之間。" } diff --git a/packages/errors/src/i18n/weblate/messages.json b/packages/errors/src/i18n/weblate/messages.json index 26e36ec63..3f831556e 100644 --- a/packages/errors/src/i18n/weblate/messages.json +++ b/packages/errors/src/i18n/weblate/messages.json @@ -445,16 +445,32 @@ "stripe.webhook_not_available": "Webhook processing isn't available.", "stripe.webhook_signature_invalid": "Stripe webhook signature is invalid.", "stripe.webhook_signature_missing": "Stripe webhook signature is missing.", + "threads.channel_has_threads": "Channels with threads can't be converted to announcement channels.", + "threads.forum_tag_names_must_be_unique": "Tag names must be unique.", + "threads.forum_tag_required": "A tag is required to create a post in this channel.", + "threads.hide_media_download_option_media_only": "Hiding media download options is only available in media channels.", + "threads.invalid_thread_notification_settings": "Invalid thread notification settings.", + "threads.max_active_threads_reached": "This community has reached its active thread limit of {count}.", + "threads.max_forum_tags_reached": "This channel has reached its tag limit of {count}.", + "threads.max_pinned_threads_reached": "This channel has reached its pinned post limit of {count}.", + "threads.max_thread_members_reached": "This thread has reached its member limit of {count}.", + "threads.no_tags_available_to_non_moderators": "A tag is required, but only moderators can apply the tags in this channel.", + "threads.search_index_not_ready": "The search index isn't ready yet. Try again in a moment.", + "threads.thread_already_created_for_message": "A thread has already been created for this message.", + "threads.thread_archived": "This thread is archived.", + "threads.thread_locked": "This thread is locked.", "tickets.ticket_already_completed": "This ticket has already been completed.", "tos_and_age.date_of_birth_format_invalid": "Invalid date of birth format.", "tos_and_age.must_agree_to_tos_and_privacy": "You must agree to the Terms of service and Privacy policy.", "tos_and_age.must_be_minimum_age": "You must be at least {minAge, plural, one {# year} other {# years}} old to create an account.", "unknown_entities.unknown_channel": "Channel wasn't found.", + "unknown_entities.unknown_forum_tag": "Tag wasn't found.", "unknown_entities.unknown_guild": "Community wasn't found.", "unknown_entities.unknown_invite": "Invite wasn't found or is no longer valid.", "unknown_entities.unknown_member": "Member wasn't found in this community.", "unknown_entities.unknown_message": "Message wasn't found.", "unknown_entities.unknown_role": "Role wasn't found.", + "unknown_entities.unknown_thread_member": "Thread member wasn't found.", "unknown_entities.unknown_user": "User wasn't found.", "unknown_entities.unknown_user_flag": "The specified user flag isn't recognized.", "urls.invalid_url_format": "Invalid URL format.", @@ -487,8 +503,12 @@ "vanity_urls.vanity_url_code_length_invalid": "Vanity URL code must be between {min} and {max} characters.", "vanity_urls.vanity_url_invalid_characters": "Vanity URL can only contain lowercase letters (a-z), digits (0-9), and hyphens (-).", "webhooks.cannot_modify_system_webhook": "You can't edit system messages.", + "webhooks.forum_target_conflict": "You can't specify both `thread_id` and `thread_name`.", + "webhooks.forum_target_required": "Webhooks posting to this channel need a `thread_id` or a `thread_name`.", "webhooks.max_webhooks_per_channel_reached": "You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per channel.", "webhooks.max_webhooks_per_guild_reached": "You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per community.", + "webhooks.service_forum_unsupported": "This webhook service can't post to a forum or media channel without a `thread_id`.", + "webhooks.thread_name_requires_forum": "`thread_name` and `applied_tags` only work in forum and media channels.", "webhooks.unknown_webhook": "Unknown webhook.", "webhooks.webhook_name_length_invalid": "Webhook name must be between {min} and {max} characters." } diff --git a/packages/hono/src/middleware/Metrics.ts b/packages/hono/src/middleware/Metrics.ts index 8e4dce9f5..f464e32b8 100644 --- a/packages/hono/src/middleware/Metrics.ts +++ b/packages/hono/src/middleware/Metrics.ts @@ -2,7 +2,7 @@ import {isLoopbackIpAddress} from '@fluxer/ip_utils/src/IpAddress'; import type {HttpBindings} from '@hono/node-server'; -import type {Handler, MiddlewareHandler} from 'hono'; +import type {Context, Handler, MiddlewareHandler} from 'hono'; const SKIP_PATHS = new Set(['/_health', '/_healthz', '/_metrics']); @@ -24,11 +24,11 @@ function statusClass(status: number): string { return '5xx'; } -class Counter { +export class Counter { private labels = new Map(); - inc(labelKey: string = ''): void { - this.labels.set(labelKey, (this.labels.get(labelKey) ?? 0) + 1); + inc(labelKey: string = '', amount = 1): void { + this.labels.set(labelKey, (this.labels.get(labelKey) ?? 0) + amount); } render(name: string, help: string): string { @@ -50,7 +50,7 @@ class Counter { } } -class Histogram { +export class Histogram { private readonly buckets: Array; private readonly counts: Array; private sum = 0; @@ -85,7 +85,7 @@ class Histogram { } } -class Gauge { +export class Gauge { private valueFn: () => number; constructor(valueFn: () => number) { @@ -119,6 +119,62 @@ function renderRegisteredSections(): Array { return rendered; } +interface RegisteredMetric { + help: string; + metric: Counter | Histogram | Gauge; +} + +const registeredMetrics = new Map(); + +function register( + name: string, + help: string, + create: () => T, + kind: new (...args: Array) => T, +): T { + const existing = registeredMetrics.get(name); + if (existing !== undefined) { + if (!(existing.metric instanceof kind)) { + throw new Error(`metric ${name} is already registered with another type`); + } + return existing.metric; + } + const metric = create(); + registeredMetrics.set(name, {help, metric}); + return metric; +} + +export function registerCounter(name: string, help: string): Counter { + return register(name, help, () => new Counter(), Counter); +} + +export function registerHistogram(name: string, help: string, buckets: Array = DEFAULT_BUCKETS): Histogram { + return register(name, help, () => new Histogram(buckets), Histogram); +} + +export function registerGauge(name: string, help: string, valueFn: () => number): Gauge { + return register(name, help, () => new Gauge(valueFn), Gauge); +} + +function renderRegisteredMetrics(): Array { + return [...registeredMetrics].map(([name, {help, metric}]) => metric.render(name, help)); +} + +const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'; + +function renderMetricsResponse(c: Context, sections: Array): Response { + if (!isLoopbackPeer(c.env)) { + return c.text('FORBIDDEN', 403, {'Content-Type': 'text/plain'}); + } + return c.text(`${[...sections, ...renderRegisteredMetrics()].join('\n\n')}\n`, 200, { + 'Content-Type': METRICS_CONTENT_TYPE, + }); +} + +export function createRegisteredMetricsHandler(): Handler { + return (c) => renderMetricsResponse(c, []); +} + interface MetricsState { requestsTotal: Counter; errorsTotal: Counter; @@ -165,21 +221,14 @@ export function createMetricsMiddleware(serviceName: string): MetricsResult { } }; - const metricsHandler: Handler = (c) => { - if (!isLoopbackPeer(c.env)) { - return c.text('FORBIDDEN', 403, {'Content-Type': 'text/plain'}); - } - const sections = [ + const metricsHandler: Handler = (c) => + renderMetricsResponse(c, [ requestsTotal.render(`${prefix}_http_requests_total`, 'Total HTTP requests'), requestDuration.render(`${prefix}_http_request_duration_seconds`, 'HTTP request duration in seconds'), errorsTotal.render(`${prefix}_http_errors_total`, 'Total HTTP 5xx errors'), uptime.render(`${prefix}_uptime_seconds`, 'Process uptime in seconds'), ...renderRegisteredSections(), - ]; - return c.text(`${sections.join('\n\n')}\n`, 200, { - 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', - }); - }; + ]); return {middleware, metricsHandler, state}; } diff --git a/packages/hono/src/middleware/tests/Metrics.test.ts b/packages/hono/src/middleware/tests/Metrics.test.ts index eb42c5090..48ce32df2 100644 --- a/packages/hono/src/middleware/tests/Metrics.test.ts +++ b/packages/hono/src/middleware/tests/Metrics.test.ts @@ -1,6 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {createMetricsMiddleware, registerMetricsSection} from '@fluxer/hono/src/middleware/Metrics'; +import { + createMetricsMiddleware, + createRegisteredMetricsHandler, + registerCounter, + registerGauge, + registerHistogram, + registerMetricsSection, +} from '@fluxer/hono/src/middleware/Metrics'; import {Hono} from 'hono'; import {describe, expect, test} from 'vitest'; @@ -265,4 +272,44 @@ describe('Metrics Middleware', () => { expect(await (await requestMetrics(app)).text()).not.toContain('fluxer_test_extra'); }); }); + + describe('registered metrics', () => { + test('renders registered counters, gauges and histograms after the http metrics', async () => { + const counter = registerCounter('fluxer_test_registered_total', 'Registered counter'); + counter.inc('kind="a"'); + counter.inc('kind="a"', 2); + registerGauge('fluxer_test_registered_gauge', 'Registered gauge', () => 7); + registerHistogram('fluxer_test_registered_seconds', 'Registered histogram', [1, 5]).observe(2); + const {app} = createTestApp(); + const body = await (await requestMetrics(app)).text(); + expect(body).toContain('# TYPE fluxer_test_registered_total counter'); + expect(body).toContain('fluxer_test_registered_total{kind="a"} 3'); + expect(body).toContain('fluxer_test_registered_gauge 7'); + expect(body).toContain('fluxer_test_registered_seconds_bucket{le="5"} 1'); + expect(body.indexOf('fluxer_test_uptime_seconds')).toBeLessThan(body.indexOf('fluxer_test_registered_total')); + }); + + test('returns the same metric when registered twice', () => { + const first = registerCounter('fluxer_test_registered_twice_total', 'Registered twice'); + expect(registerCounter('fluxer_test_registered_twice_total', 'Registered twice')).toBe(first); + }); + + test('refuses to register one name as two types', () => { + registerCounter('fluxer_test_registered_clash', 'Clash'); + expect(() => registerGauge('fluxer_test_registered_clash', 'Clash', () => 0)).toThrow(); + }); + + test('serves only registered metrics from the standalone handler', async () => { + registerCounter('fluxer_test_standalone_total', 'Standalone counter').inc(); + const app = new Hono(); + app.get('/_metrics', createRegisteredMetricsHandler()); + const res = await requestMetrics(app); + const body = await res.text(); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('text/plain; version=0.0.4; charset=utf-8'); + expect(body).toContain('fluxer_test_standalone_total 1'); + expect(body).not.toContain('_http_requests_total'); + expect((await requestMetrics(app, '8.8.8.8')).status).toBe(403); + }); + }); }); diff --git a/packages/openapi/src/OpenAPITypes.ts b/packages/openapi/src/OpenAPITypes.ts index 3f1876153..f6092550e 100644 --- a/packages/openapi/src/OpenAPITypes.ts +++ b/packages/openapi/src/OpenAPITypes.ts @@ -19,8 +19,10 @@ export interface ExtractedRoute { middlewares: Array; hasLoginRequired: boolean; hasDefaultUserOnly: boolean; + hasBotOnly: boolean; rateLimitConfig: string | null; responseSchemaName: string | null; + acceptedResponseSchemaName?: string | null; responseContentType: string; hasNoContent: boolean; bodylessStatusCodes: Array; @@ -39,6 +41,7 @@ export interface ExtractedRoute { explicitTags: Array | null; explicitDeprecated: boolean; explicitExternalDocs: OpenAPIExternalDocs | null; + explicitExperiment: string | null; } export interface OpenAPIPathItem { [method: string]: OpenAPIOperation; @@ -54,6 +57,7 @@ export interface OpenAPIOperation { responses: Record; deprecated?: boolean; externalDocs?: OpenAPIExternalDocs; + 'x-fluxer-experiment'?: string; } export interface OpenAPIParameter { name: string; diff --git a/packages/openapi/src/__tests__/OpenAPIGenerator.test.ts b/packages/openapi/src/__tests__/OpenAPIGenerator.test.ts index eaab24a0c..68e2b0292 100644 --- a/packages/openapi/src/__tests__/OpenAPIGenerator.test.ts +++ b/packages/openapi/src/__tests__/OpenAPIGenerator.test.ts @@ -16,16 +16,54 @@ describe('OpenAPI generation from API controllers', () => { required: false, content: {'application/json': {schema: {$ref: '#/components/schemas/ChannelUpdateRequestBody'}}}, }); - const options = document.components.schemas.ChannelUpdateRequestBody.anyOf; - expect(options).toHaveLength(6); - const withType = (options ?? []).filter((option) => option.properties && 'type' in option.properties); + const options = document.components.schemas.ChannelUpdateRequestBody.anyOf ?? []; + expect(options).toHaveLength(11); + const withType = options.filter((option) => option.properties && 'type' in option.properties); expect(withType).toHaveLength(2); for (const option of withType) { expect(option.properties?.type).toMatchObject({anyOf: [{const: 0}, {const: 5}]}); } - for (const option of options ?? []) { + for (const option of options.slice(0, 6)) { expect(option.required).toBeUndefined(); } + expect(options.slice(6)).toEqual([ + {$ref: '#/components/schemas/ChannelUpdatePublicThreadRequestBody'}, + {$ref: '#/components/schemas/ChannelUpdatePrivateThreadRequestBody'}, + {$ref: '#/components/schemas/ChannelUpdateThreadParentRequestBody'}, + {$ref: '#/components/schemas/ChannelUpdateForumRequestBody'}, + {$ref: '#/components/schemas/ChannelUpdateMediaRequestBody'}, + ]); + for (const name of [ + 'ChannelUpdateThreadParentRequestBody', + 'ChannelUpdateForumRequestBody', + 'ChannelUpdateMediaRequestBody', + ]) { + const schema = document.components.schemas[name]; + expect(schema['x-fluxer-experiment']).toBe('channel_threads'); + expect(schema.properties).not.toHaveProperty('type'); + expect(schema.properties).toHaveProperty('default_auto_archive_duration'); + } + expect(document.components.schemas.ChannelUpdateForumRequestBody.properties).toHaveProperty('available_tags'); + expect(document.components.schemas.ChannelCreateRequest.oneOf).toEqual( + expect.arrayContaining([ + {$ref: '#/components/schemas/GuildForumChannelCreateRequest'}, + {$ref: '#/components/schemas/GuildMediaChannelCreateRequest'}, + ]), + ); + for (const name of ['ChannelUpdatePublicThreadRequestBody', 'ChannelUpdatePrivateThreadRequestBody']) { + const schema = document.components.schemas[name]; + expect(schema['x-fluxer-experiment']).toBe('channel_threads'); + expect(schema.properties).not.toHaveProperty('type'); + expect(schema.properties).toHaveProperty('archived'); + expect(schema.required).toBeUndefined(); + } + expect(document.components.schemas.ChannelUpdatePrivateThreadRequestBody.properties).toHaveProperty('invitable'); + }); + + it('documents the bodyless response of thread member settings', () => { + expect(document.paths['/channels/{channel_id}/thread-members/@me/settings'].patch.responses['204']).toEqual({ + description: 'No Content', + }); }); it('keeps path-supplied voice IDs out of JSON request bodies', () => { @@ -45,6 +83,39 @@ describe('OpenAPI generation from API controllers', () => { ]); }); + it('tags every channel thread operation with its experiment', () => { + const tagged = Object.values(document.paths) + .flatMap((pathItem) => Object.values(pathItem)) + .filter((operation) => operation['x-fluxer-experiment'] === 'channel_threads') + .map((operation) => operation.operationId) + .sort(); + expect(tagged).toEqual( + [ + 'start_thread_from_message', + 'start_thread', + 'list_guild_active_threads', + 'list_public_archived_threads', + 'list_private_archived_threads', + 'list_joined_private_archived_threads', + 'list_thread_members', + 'get_thread_member', + 'join_thread', + 'add_thread_member', + 'leave_thread', + 'remove_thread_member', + 'update_thread_member_settings', + 'search_threads', + 'get_channel_post_data', + 'create_forum_tag', + 'update_forum_tag', + 'delete_forum_tag', + 'list_admin_guild_threads', + 'delete_admin_thread_channel', + ].sort(), + ); + expect(document.paths['/channels/{channel_id}'].get).not.toHaveProperty('x-fluxer-experiment'); + }); + it('retains the explicit optional sudo body on channel deletion', () => { expect(document.paths['/channels/{channel_id}'].delete.requestBody).toMatchObject({ required: false, diff --git a/packages/openapi/src/converters/ZodToOpenAPI.ts b/packages/openapi/src/converters/ZodToOpenAPI.ts index 90d7190e4..bad489c29 100644 --- a/packages/openapi/src/converters/ZodToOpenAPI.ts +++ b/packages/openapi/src/converters/ZodToOpenAPI.ts @@ -36,6 +36,7 @@ function applyMetadata(schema: core.$ZodType, json: OpenAPISchema): void { if (!metadata) return; if (metadata.format) json.format = metadata.format; if (metadata.bitflagValues) json['x-bitflagValues'] = metadata.bitflagValues; + if (metadata.experiment) json['x-fluxer-experiment'] = metadata.experiment; const entries = metadata.enumEntries; if (!entries) return; json['x-enumNames'] = entries.map((entry) => entry.name); diff --git a/packages/openapi/src/extractors/RouteExtractor.ts b/packages/openapi/src/extractors/RouteExtractor.ts index 90f4052bf..a21812089 100644 --- a/packages/openapi/src/extractors/RouteExtractor.ts +++ b/packages/openapi/src/extractors/RouteExtractor.ts @@ -25,7 +25,12 @@ import { import {z} from 'zod'; const HTTP_METHODS: ReadonlySet = new Set(['get', 'post', 'put', 'patch', 'delete']); -const SCHEMA_METADATA_FIELDS: ReadonlySet = new Set(['responseSchema', 'requestSchema', 'requestFormSchema']); +const SCHEMA_METADATA_FIELDS: ReadonlySet = new Set([ + 'responseSchema', + 'acceptedResponseSchema', + 'requestSchema', + 'requestFormSchema', +]); const MetadataStringList = z .union([z.string(), z.array(z.string())]) .transform((value) => (typeof value === 'string' ? [value] : value)); @@ -36,6 +41,7 @@ const RouteMetadata = z.strictObject({ summary: z.string().min(1), description: z.string(), responseSchema: z.string().min(1).nullable(), + acceptedResponseSchema: z.string().min(1).optional(), responseContentType: z.string().optional(), requestSchema: z.string().min(1).optional(), requestFormSchema: z.string().min(1).optional(), @@ -52,6 +58,10 @@ const RouteMetadata = z.strictObject({ tags: MetadataStringList, deprecated: z.boolean().optional(), externalDocs: z.strictObject({url: z.string(), description: z.string().optional()}).optional(), + experiment: z + .string() + .regex(/^[a-z][a-z0-9_]*$/) + .optional(), }); const RouteOptionsMetadata = RouteMetadata.pick({description: true, responseContentType: true}).extend({ description: z.string().min(1), @@ -130,6 +140,13 @@ function parseObjectLiteralMetadata( } function extractValidatorInfo(callExpr: CallExpression): ExtractedValidator | null { const expression = callExpr.getExpression(); + if (Node.isIdentifier(expression) && expression.getText() === 'GatedJsonValidator') { + const [controlArg] = callExpr.getArguments(); + if (!controlArg || !Node.isIdentifier(controlArg)) { + throw new Error(`GatedJsonValidator must use a named control schema export: ${callExpr.getText()}`); + } + return {target: 'json', schemaName: controlArg.getText()}; + } if (!Node.isIdentifier(expression) || expression.getText() !== 'Validator') return null; const [targetArg, schemaArg] = callExpr.getArguments(); if (!targetArg || !schemaArg) throw new Error(`Validator requires a target and named schema: ${callExpr.getText()}`); @@ -144,6 +161,7 @@ interface MiddlewareInfo { middlewareName: string; rateLimitConfig?: string; responseSchemaName?: string; + acceptedResponseSchemaName?: string; responseContentType?: string; hasNoContent?: boolean; bodylessStatusCodes?: Array | null; @@ -161,6 +179,7 @@ interface MiddlewareInfo { explicitTags?: Array | null; explicitDeprecated?: boolean; explicitExternalDocs?: OpenAPIExternalDocs; + explicitExperiment?: string; } function extractOpenAPIMetadata(args: ReadonlyArray, context: MetadataContext): MiddlewareInfo { const [first, summary, responseSchema, options] = args; @@ -185,6 +204,7 @@ function extractOpenAPIMetadata(args: ReadonlyArray, context: MetadataCont return { middlewareName: 'OpenAPI', responseSchemaName: metadata.responseSchema ?? undefined, + acceptedResponseSchemaName: metadata.acceptedResponseSchema, hasNoContent: metadata.responseSchema === null, bodylessStatusCodes: metadata.bodylessStatusCodes, responseContentType: metadata.responseContentType, @@ -199,6 +219,7 @@ function extractOpenAPIMetadata(args: ReadonlyArray, context: MetadataCont explicitTags: metadata.tags, explicitDeprecated: metadata.deprecated, explicitExternalDocs: metadata.externalDocs, + explicitExperiment: metadata.experiment, }; } function extractMiddlewareInfo(callExpr: CallExpression, context: MetadataContext): MiddlewareInfo | null { @@ -375,6 +396,7 @@ function buildRoute( middlewares: [], hasLoginRequired: false, hasDefaultUserOnly: false, + hasBotOnly: false, rateLimitConfig: null, responseSchemaName: null, responseContentType: 'application/json', @@ -395,6 +417,7 @@ function buildRoute( explicitTags: null, explicitDeprecated: false, explicitExternalDocs: null, + explicitExperiment: null, }; const context = {resolver, scope}; for (const arg of registration.middlewareArguments) { @@ -403,6 +426,7 @@ function buildRoute( route.middlewares.push(name); if (name === 'LoginRequired') route.hasLoginRequired = true; if (name === 'DefaultUserOnly') route.hasDefaultUserOnly = true; + if (name === 'BotOnly') route.hasBotOnly = true; continue; } if (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) { @@ -420,6 +444,9 @@ function buildRoute( route.middlewares.push(middleware.middlewareName); if (middleware.rateLimitConfig) route.rateLimitConfig = middleware.rateLimitConfig; if (middleware.responseSchemaName) route.responseSchemaName = middleware.responseSchemaName; + if (middleware.acceptedResponseSchemaName) { + route.acceptedResponseSchemaName = middleware.acceptedResponseSchemaName; + } if (middleware.responseContentType) route.responseContentType = middleware.responseContentType; if (middleware.hasNoContent) route.hasNoContent = true; if (middleware.bodylessStatusCodes) route.bodylessStatusCodes = middleware.bodylessStatusCodes; @@ -450,6 +477,7 @@ function buildRoute( if (middleware.explicitTags) route.explicitTags = middleware.explicitTags; if (middleware.explicitDeprecated) route.explicitDeprecated = true; if (middleware.explicitExternalDocs) route.explicitExternalDocs = middleware.explicitExternalDocs; + if (middleware.explicitExperiment) route.explicitExperiment = middleware.explicitExperiment; } return route; } diff --git a/packages/openapi/src/generator/OpenAPIOperationBuilder.ts b/packages/openapi/src/generator/OpenAPIOperationBuilder.ts index 2a390803c..24fa98757 100644 --- a/packages/openapi/src/generator/OpenAPIOperationBuilder.ts +++ b/packages/openapi/src/generator/OpenAPIOperationBuilder.ts @@ -21,6 +21,9 @@ function successStatusCodes(route: ExtractedRoute): Array { if (route.successStatusCodes.length) return route.successStatusCodes; return [route.hasNoContent ? 204 : 200]; } +function isBodyless(route: ExtractedRoute, code: number): boolean { + return code === 204 || route.bodylessStatusCodes.includes(code) || (route.hasNoContent && !route.responseSchemaName); +} export class OpenAPIOperationBuilder { private readonly schemaRegistry: SchemaRegistry; private readonly usedOperationIds: Set; @@ -64,6 +67,9 @@ export class OpenAPIOperationBuilder { if (route.explicitExternalDocs) { operation.externalDocs = route.explicitExternalDocs; } + if (route.explicitExperiment) { + operation['x-fluxer-experiment'] = route.explicitExperiment; + } if (security.length > 0) { operation.security = security; } @@ -90,6 +96,9 @@ export class OpenAPIOperationBuilder { if (route.path === '/users/@me' || route.path.startsWith('/users/@me/')) { return [{bearerToken: []}, {sessionToken: []}]; } + if (route.hasBotOnly) { + return [{botToken: []}]; + } if (!route.hasLoginRequired && !route.hasDefaultUserOnly) { return []; } @@ -202,21 +211,31 @@ export class OpenAPIOperationBuilder { responses['304'] = {description: 'Not Modified'}; continue; } - if ( - code === 204 || - route.bodylessStatusCodes.includes(code) || - (route.hasNoContent && !route.responseSchemaName) - ) { + if (code === 202 && route.acceptedResponseSchemaName) { + responses['202'] = { + description: 'Accepted', + content: { + 'application/json': {schema: this.schemaRegistry.getRef(route.acceptedResponseSchemaName, 'output')}, + }, + }; + continue; + } + if (isBodyless(route, code)) { responses[String(code)] = {description: code === 204 ? 'No Content' : 'Success'}; continue; } if (!route.responseSchemaName) { throw new Error(`Missing response schema for ${route.method.toUpperCase()} ${route.path}`); } + const ref = this.schemaRegistry.getRef(route.responseSchemaName, 'output'); responses[String(code)] = { description: 'Success', content: { - [route.responseContentType]: {schema: this.schemaRegistry.getRef(route.responseSchemaName, 'output')}, + [route.responseContentType]: { + schema: statusCodes.some((other) => other >= 200 && other < 300 && isBodyless(route, other)) + ? this.schemaRegistry.nullable(ref) + : ref, + }, }, }; } diff --git a/packages/openapi/src/generator/__tests__/OpenAPIOperationResponses.test.ts b/packages/openapi/src/generator/__tests__/OpenAPIOperationResponses.test.ts new file mode 100644 index 000000000..ded316de8 --- /dev/null +++ b/packages/openapi/src/generator/__tests__/OpenAPIOperationResponses.test.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {extractRoutesFromControllers} from '@fluxer/openapi/src/extractors/RouteExtractor'; +import {OpenAPIOperationBuilder} from '@fluxer/openapi/src/generator/OpenAPIOperationBuilder'; +import type {OpenAPISchemaTarget} from '@fluxer/openapi/src/OpenAPIGenerationTypes'; +import type {ExtractedRoute} from '@fluxer/openapi/src/OpenAPITypes'; +import {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry'; +import {afterAll, beforeAll, describe, expect, it} from 'vitest'; + +const CONTROLLER_SOURCE = ` +export function FixtureController(app: HonoApp) { + app.patch( + '/fixture/maybe', + LoginRequired, + OpenAPI({ + operationId: 'fixture_maybe', + summary: 'Fixture', + description: 'Fixture route.', + responseSchema: FixtureResponse, + statusCode: [200, 204], + bodylessStatusCodes: [204], + tags: ['Fixture'], + }), + async (ctx) => ctx.body(null, 204), + ); + app.get( + '/fixture/always', + LoginRequired, + OpenAPI({ + operationId: 'fixture_always', + summary: 'Fixture', + description: 'Fixture route.', + responseSchema: FixtureResponse, + tags: ['Fixture'], + }), + async (ctx) => ctx.json({}), + ); +} +`; + +const REF = {$ref: '#/components/schemas/FixtureResponse'}; + +describe('operation responses', () => { + let directory: string; + let routes: Map; + beforeAll(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-responses-')); + const controllerPath = path.join(directory, 'FixtureController.ts'); + fs.writeFileSync(controllerPath, CONTROLLER_SOURCE); + routes = new Map(extractRoutesFromControllers([controllerPath]).map((route) => [route.path, route])); + }); + afterAll(() => { + fs.rmSync(directory, {recursive: true, force: true}); + }); + + function successSchema(routePath: string, target?: OpenAPISchemaTarget) { + const route = routes.get(routePath); + if (!route) throw new Error(`Missing fixture route ${routePath}`); + const schemaRegistry = new SchemaRegistry(target); + schemaRegistry.register('FixtureResponse', {type: 'object'}); + const builder = new OpenAPIOperationBuilder({schemaRegistry, usedOperationIds: new Set()}); + const responses = builder.buildOperation(route).responses; + return {schema: responses['200'].content?.['application/json'].schema, responses}; + } + + it('marks the body nullable when another success status has no body', () => { + const {schema, responses} = successSchema('/fixture/maybe'); + expect(schema).toEqual({anyOf: [REF, {type: 'null'}]}); + expect(responses['204']).toEqual({description: 'No Content'}); + }); + + it('uses nullable for the openapi 3.0 target', () => { + expect(successSchema('/fixture/maybe', 'openapi-3.0').schema).toEqual({allOf: [REF], nullable: true}); + }); + + it('keeps a plain reference when every success status has a body', () => { + expect(successSchema('/fixture/always').schema).toEqual(REF); + }); +}); diff --git a/packages/openapi/src/generator/__tests__/OpenAPIOperationSecurity.test.ts b/packages/openapi/src/generator/__tests__/OpenAPIOperationSecurity.test.ts new file mode 100644 index 000000000..723840721 --- /dev/null +++ b/packages/openapi/src/generator/__tests__/OpenAPIOperationSecurity.test.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {extractRoutesFromControllers} from '@fluxer/openapi/src/extractors/RouteExtractor'; +import {OpenAPIOperationBuilder} from '@fluxer/openapi/src/generator/OpenAPIOperationBuilder'; +import type {ExtractedRoute} from '@fluxer/openapi/src/OpenAPITypes'; +import {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry'; +import {afterAll, beforeAll, describe, expect, it} from 'vitest'; + +const CONTROLLER_SOURCE = ` +export function FixtureController(app: HonoApp) { + app.get( + '/fixture/bot', + LoginRequired, + BotOnly, + OpenAPI({ + operationId: 'fixture_bot', + summary: 'Fixture', + description: 'Fixture route.', + responseSchema: null, + statusCode: 204, + tags: ['Fixture'], + }), + async (ctx) => ctx.body(null, 204), + ); + app.get( + '/fixture/user', + LoginRequired, + DefaultUserOnly, + OpenAPI({ + operationId: 'fixture_user', + summary: 'Fixture', + description: 'Fixture route.', + responseSchema: null, + statusCode: 204, + tags: ['Fixture'], + }), + async (ctx) => ctx.body(null, 204), + ); + app.get( + '/fixture/any', + LoginRequired, + OpenAPI({ + operationId: 'fixture_any', + summary: 'Fixture', + description: 'Fixture route.', + responseSchema: null, + statusCode: 204, + tags: ['Fixture'], + }), + async (ctx) => ctx.body(null, 204), + ); +} +`; + +describe('operation security', () => { + let directory: string; + let routes: Map; + beforeAll(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-security-')); + const controllerPath = path.join(directory, 'FixtureController.ts'); + fs.writeFileSync(controllerPath, CONTROLLER_SOURCE); + routes = new Map(extractRoutesFromControllers([controllerPath]).map((route) => [route.path, route])); + }); + afterAll(() => { + fs.rmSync(directory, {recursive: true, force: true}); + }); + + function securityOf(routePath: string) { + const route = routes.get(routePath); + if (!route) throw new Error(`Missing fixture route ${routePath}`); + const builder = new OpenAPIOperationBuilder({schemaRegistry: new SchemaRegistry(), usedOperationIds: new Set()}); + return {route, security: builder.buildOperation(route).security}; + } + + it('declares bot token security for a BotOnly route', () => { + const {route, security} = securityOf('/fixture/bot'); + expect(route.hasBotOnly).toBe(true); + expect(security).toEqual([{botToken: []}]); + }); + + it('keeps DefaultUserOnly routes on user tokens', () => { + const {route, security} = securityOf('/fixture/user'); + expect(route.hasBotOnly).toBe(false); + expect(route.hasDefaultUserOnly).toBe(true); + expect(security).toEqual([{sessionToken: []}]); + }); + + it('keeps LoginRequired routes open to bot and user tokens', () => { + const {route, security} = securityOf('/fixture/any'); + expect(route.hasBotOnly).toBe(false); + expect(security).toEqual([{botToken: []}, {sessionToken: []}]); + }); +}); diff --git a/packages/openapi/src/registry/SchemaRegistry.ts b/packages/openapi/src/registry/SchemaRegistry.ts index a3d03b657..d39756cc2 100644 --- a/packages/openapi/src/registry/SchemaRegistry.ts +++ b/packages/openapi/src/registry/SchemaRegistry.ts @@ -12,10 +12,17 @@ export class SchemaRegistry { private readonly emptyObjectAcceptance = new WeakMap(); private readonly converter: ZodOpenAPIConverter; - constructor(target: OpenAPISchemaTarget = 'draft-2020-12', nameUnionBranches = false) { + constructor( + private readonly target: OpenAPISchemaTarget = 'draft-2020-12', + nameUnionBranches = false, + ) { this.converter = new ZodOpenAPIConverter(target, nameUnionBranches); } + nullable(schema: OpenAPISchema): OpenAPISchema { + return this.target === 'openapi-3.0' ? {allOf: [schema], nullable: true} : {anyOf: [schema, {type: 'null'}]}; + } + register(name: string, schema: OpenAPISchema): void { validateOpenAPIComponentName(name); if (this.zodSchemas.has(name)) throw new Error(`OpenAPI schema also has a Zod definition: ${name}`); diff --git a/packages/schema/src/SchemaMetadata.ts b/packages/schema/src/SchemaMetadata.ts index 968dd87ed..51bd4fad7 100644 --- a/packages/schema/src/SchemaMetadata.ts +++ b/packages/schema/src/SchemaMetadata.ts @@ -20,6 +20,7 @@ interface SchemaMetadata { format?: string; preserveEmptyValues?: boolean; preserveNullFields?: boolean; + experiment?: string; } export const schemaMetadata = z.registry(); diff --git a/packages/schema/src/contracts/search/SearchDocumentTypes.ts b/packages/schema/src/contracts/search/SearchDocumentTypes.ts index 25fbfb86b..28ec4bc52 100644 --- a/packages/schema/src/contracts/search/SearchDocumentTypes.ts +++ b/packages/schema/src/contracts/search/SearchDocumentTypes.ts @@ -228,3 +228,38 @@ export interface GuildMemberSearchFilters { sortBy?: 'joinedAt' | 'relevance'; sortOrder?: 'asc' | 'desc'; } + +export interface SearchableThread { + id: string; + guildId: string; + parentId: string; + type: number; + name: string; + ownerId: string | null; + archived: boolean; + locked: boolean; + appliedTagIds: Array; + createdAt: number; + idSequence: number; + lastMessageAt: number; + archivedAt: number; +} + +export interface ThreadSearchCursor { + createdAt: number; + idSequence: number; +} + +export interface ThreadSearchFilters { + guildId: string; + parentId: string; + publicOnly?: boolean; + privateThreadIds?: Array; + archived?: boolean; + tagIds?: Array; + tagSetting?: 'match_some' | 'match_all'; + after?: ThreadSearchCursor; + before?: ThreadSearchCursor; + sortBy?: 'last_message_time' | 'archive_time' | 'relevance' | 'creation_time'; + sortOrder?: 'asc' | 'desc'; +} diff --git a/packages/schema/src/domains/admin/AdminSchemas.ts b/packages/schema/src/domains/admin/AdminSchemas.ts index 233fa49c4..66491fc66 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -12,6 +12,10 @@ import {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/Admin import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; import {UserAdminResponseSchema} from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; import {CaptchaConfigResponse, CaptchaConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/CaptchaSchemas'; +import { + ChannelThreadsConfigResponse, + ChannelThreadsConfigUpdateRequest, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; import { DomainMigrationConfigResponse, DomainMigrationConfigUpdateRequest, @@ -139,6 +143,7 @@ const SearchIndexTypeEnum = createNamedStringLiteralUnion( ['guild_members', 'guild_members', 'Guild member search index'], ['favorite_memes', 'favorite_memes', 'Favourite meme search index'], ['discovery', 'discovery', 'Discovery guild search index'], + ['threads', 'threads', 'Thread search index (channel_threads experiment)'], ], 'Type of search index to refresh', ); @@ -673,6 +678,7 @@ export const InstanceConfigResponse = z.object({ domain_migration: DomainMigrationConfigResponse, plutonium_page: PlutoniumPageConfigResponse, captcha: CaptchaConfigResponse, + channel_threads: ChannelThreadsConfigResponse, experiment_delivery: ExperimentDeliveryConfigResponse, registration: InstanceRegistrationResponse, self_hosted: z.boolean(), @@ -708,6 +714,7 @@ export const InstanceConfigUpdateRequest = z.object({ domain_migration: DomainMigrationConfigUpdateRequest.nullish(), plutonium_page: PlutoniumPageConfigUpdateRequest.nullish(), captcha: CaptchaConfigUpdateRequest.nullish(), + channel_threads: ChannelThreadsConfigUpdateRequest.nullish(), experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(), registration: z .object({ diff --git a/packages/schema/src/domains/admin/AdminThreadSchemas.ts b/packages/schema/src/domains/admin/AdminThreadSchemas.ts new file mode 100644 index 000000000..4ea1574e8 --- /dev/null +++ b/packages/schema/src/domains/admin/AdminThreadSchemas.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import {z} from 'zod'; + +export const ListGuildThreadsResponse = z.object({ + threads: z.array(ThreadChannelResponse).describe('Every thread of the guild, active and archived, newest first'), +}); + +export type ListGuildThreadsResponse = z.infer; diff --git a/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts b/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts new file mode 100644 index 000000000..eaee469ee --- /dev/null +++ b/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + applyChannelThreadsConfigUpdate, + type ChannelThreadsConfig, + ChannelThreadsConfigSchema, + ChannelThreadsConfigUpdateRequest, + channelThreadsGuildActive, + channelThreadsGuildFieldsChanged, + channelThreadsUserActive, + channelThreadsUserExcluded, + channelThreadsUserFieldsChanged, + compileChannelThreadsConfig, + DEFAULT_CHANNEL_THREADS_CONFIG, + DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG, + resolveChannelThreadsAssignment, +} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; +import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket'; +import vectors from '@fluxer/schema/src/domains/experiment/ExperimentBucketVectors.json' with {type: 'json'}; +import {describe, expect, test} from 'vitest'; + +const GUILD_ID = '1000000000000000001'; +const OTHER_GUILD_ID = '1000000000000000002'; +const USER_ID = '1100000000000000001'; +const OTHER_USER_ID = '1100000000000000002'; + +function config(overrides: Partial = {}): ChannelThreadsConfig { + return {...DEFAULT_CHANNEL_THREADS_CONFIG, ...overrides}; +} + +function compiled(overrides: Partial = {}) { + return compileChannelThreadsConfig(config(overrides)); +} + +function syntheticIds(count: number): Array { + return Array.from({length: count}, (_, index) => (1400000000000000000n + BigInt(index)).toString()); +} + +describe('channel threads configuration', () => { + test('defaults to fully off', () => { + expect(DEFAULT_CHANNEL_THREADS_CONFIG).toEqual({ + enabled: false, + config_version: 0, + ever_enabled: false, + guild_basis_points: 0, + guild_salt: 'channel-threads-guild-v1', + enabled_guild_ids: [], + disabled_guild_ids: [], + user_basis_points: 0, + user_salt: 'channel-threads-user-v1', + included_user_ids: [], + excluded_user_ids: [], + }); + expect(channelThreadsGuildActive(DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG, GUILD_ID)).toBe(false); + expect(channelThreadsUserActive(DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG, USER_ID)).toBe(false); + }); + + test('keeps partial updates free of defaults and server-maintained fields', () => { + expect(ChannelThreadsConfigUpdateRequest.parse({enabled: true})).toEqual({enabled: true}); + expect(ChannelThreadsConfigUpdateRequest.parse({config_version: 9, ever_enabled: false})).toEqual({}); + }); + + test.each([ + {guild_basis_points: -1}, + {guild_basis_points: 10001}, + {user_basis_points: 1.5}, + {guild_salt: ' '}, + {user_salt: 'channel-threads-é'}, + {guild_salt: 'x'.repeat(65)}, + {enabled_guild_ids: ['guild']}, + {excluded_user_ids: ['123456789012345678901']}, + ])('rejects invalid values in stored config and updates: %j', (value) => { + expect(ChannelThreadsConfigSchema.safeParse(value).success).toBe(false); + expect(ChannelThreadsConfigUpdateRequest.safeParse(value).success).toBe(false); + }); + + test('caps every targeted id list at a thousand', () => { + const ids = syntheticIds(1001); + for (const key of ['enabled_guild_ids', 'disabled_guild_ids', 'included_user_ids', 'excluded_user_ids']) { + expect(ChannelThreadsConfigSchema.safeParse({[key]: ids}).success).toBe(false); + expect(ChannelThreadsConfigSchema.safeParse({[key]: ids.slice(0, 1000)}).success).toBe(true); + } + }); + + test('bumps the version on every update and keeps ever_enabled sticky', () => { + const first = applyChannelThreadsConfigUpdate(DEFAULT_CHANNEL_THREADS_CONFIG, {user_basis_points: 100}); + expect(first.config_version).toBe(1); + expect(first.ever_enabled).toBe(false); + const enabled = applyChannelThreadsConfigUpdate(first, {enabled: true}); + expect(enabled.config_version).toBe(2); + expect(enabled.ever_enabled).toBe(true); + const disabled = applyChannelThreadsConfigUpdate(enabled, {enabled: false}); + expect(disabled.config_version).toBe(3); + expect(disabled.enabled).toBe(false); + expect(disabled.ever_enabled).toBe(true); + }); +}); + +describe('channel threads predicates', () => { + test('the master switch overrides every list', () => { + const cfg = compiled({ + enabled_guild_ids: [GUILD_ID], + included_user_ids: [USER_ID], + guild_basis_points: 10000, + user_basis_points: 10000, + }); + expect(channelThreadsGuildActive(cfg, GUILD_ID)).toBe(false); + expect(channelThreadsUserActive(cfg, USER_ID)).toBe(false); + expect(resolveChannelThreadsAssignment(cfg, USER_ID)).toBeUndefined(); + }); + + test('exclusion beats inclusion and inclusion beats the bucket', () => { + const cfg = compiled({ + enabled: true, + enabled_guild_ids: [GUILD_ID], + disabled_guild_ids: [GUILD_ID], + included_user_ids: [USER_ID, OTHER_USER_ID], + excluded_user_ids: [USER_ID], + }); + expect(channelThreadsGuildActive(cfg, GUILD_ID)).toBe(false); + expect(channelThreadsGuildActive(cfg, OTHER_GUILD_ID)).toBe(false); + expect(channelThreadsUserActive(cfg, USER_ID)).toBe(false); + expect(channelThreadsUserActive(cfg, OTHER_USER_ID)).toBe(true); + expect(channelThreadsUserExcluded(cfg, USER_ID)).toBe(true); + expect(channelThreadsUserExcluded(cfg, OTHER_USER_ID)).toBe(false); + }); + + test('the guild and user buckets use their own salts', () => { + const guildBucket = experimentBucket(GUILD_ID, 'channel-threads-guild-v1'); + const userBucket = experimentBucket(USER_ID, 'channel-threads-user-v1'); + expect(channelThreadsGuildActive(compiled({enabled: true, guild_basis_points: guildBucket}), GUILD_ID)).toBe(false); + expect(channelThreadsGuildActive(compiled({enabled: true, guild_basis_points: guildBucket + 1}), GUILD_ID)).toBe( + true, + ); + expect(channelThreadsUserActive(compiled({enabled: true, user_basis_points: userBucket}), USER_ID)).toBe(false); + expect(channelThreadsUserActive(compiled({enabled: true, user_basis_points: userBucket + 1}), USER_ID)).toBe(true); + }); + + test('a full rollout covers everyone who is not excluded', () => { + const cfg = compiled({enabled: true, guild_basis_points: 10000, user_basis_points: 10000}); + for (const id of syntheticIds(200)) { + expect(channelThreadsGuildActive(cfg, id)).toBe(true); + expect(channelThreadsUserActive(cfg, id)).toBe(true); + } + }); + + test('the assignment is present only for active users and carries the version', () => { + const cfg = compiled({enabled: true, config_version: 7, included_user_ids: [USER_ID]}); + expect(resolveChannelThreadsAssignment(cfg, USER_ID)).toEqual({active: true, config_version: 7}); + expect(resolveChannelThreadsAssignment(cfg, OTHER_USER_ID)).toBeUndefined(); + }); + + test('field change detection separates the guild and user dimensions', () => { + const base = config({enabled: true, enabled_guild_ids: [GUILD_ID], included_user_ids: [USER_ID]}); + expect(channelThreadsGuildFieldsChanged(base, {...base, config_version: 5})).toBe(false); + expect(channelThreadsUserFieldsChanged(base, {...base, config_version: 5})).toBe(false); + expect(channelThreadsGuildFieldsChanged(base, {...base, enabled_guild_ids: [OTHER_GUILD_ID]})).toBe(true); + expect(channelThreadsUserFieldsChanged(base, {...base, enabled_guild_ids: [OTHER_GUILD_ID]})).toBe(false); + expect(channelThreadsGuildFieldsChanged(base, {...base, excluded_user_ids: [USER_ID]})).toBe(false); + expect(channelThreadsUserFieldsChanged(base, {...base, excluded_user_ids: [USER_ID]})).toBe(true); + expect(channelThreadsGuildFieldsChanged(base, {...base, enabled_guild_ids: [GUILD_ID, GUILD_ID]})).toBe(false); + expect(channelThreadsGuildFieldsChanged(base, {...base, enabled: false})).toBe(true); + expect(channelThreadsUserFieldsChanged(base, {...base, enabled: false})).toBe(true); + }); +}); + +describe('shared experiment bucket vectors', () => { + test.each(vectors as Array<[string, string, number]>)('bucket(%s, %s) is %i', (salt, id, bucket) => { + expect(experimentBucket(id, salt)).toBe(bucket); + }); +}); diff --git a/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts b/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts new file mode 100644 index 000000000..f3545de55 --- /dev/null +++ b/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket'; +import {z} from 'zod'; + +const CHANNEL_THREADS_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION; +const CHANNEL_THREADS_MAX_TARGETED_IDS = 1000; +const DEFAULT_CHANNEL_THREADS_GUILD_SALT = 'channel-threads-guild-v1'; +const DEFAULT_CHANNEL_THREADS_USER_SALT = 'channel-threads-user-v1'; + +const CHANNEL_THREADS_SALT_PATTERN = /^[\x20-\x7e]+$/u; + +const ChannelThreadsTargetIdSchema = z.string().regex(/^\d{1,20}$/u); +const ChannelThreadsTargetIdsSchema = z.array(ChannelThreadsTargetIdSchema).max(CHANNEL_THREADS_MAX_TARGETED_IDS); +const ChannelThreadsSaltSchema = z.string().trim().min(1).max(64).regex(CHANNEL_THREADS_SALT_PATTERN); +const ChannelThreadsBasisPointsSchema = z.number().int().min(0).max(CHANNEL_THREADS_BASIS_POINTS_MAX); + +const channelThreadsConfigFields = { + enabled: z.boolean(), + config_version: z.number().int().min(0), + ever_enabled: z.boolean(), + guild_basis_points: ChannelThreadsBasisPointsSchema, + guild_salt: ChannelThreadsSaltSchema, + enabled_guild_ids: ChannelThreadsTargetIdsSchema, + disabled_guild_ids: ChannelThreadsTargetIdsSchema, + user_basis_points: ChannelThreadsBasisPointsSchema, + user_salt: ChannelThreadsSaltSchema, + included_user_ids: ChannelThreadsTargetIdsSchema, + excluded_user_ids: ChannelThreadsTargetIdsSchema, +}; + +export const ChannelThreadsConfigSchema = z.object({ + enabled: channelThreadsConfigFields.enabled.default(false), + config_version: channelThreadsConfigFields.config_version.default(0), + ever_enabled: channelThreadsConfigFields.ever_enabled.default(false), + guild_basis_points: channelThreadsConfigFields.guild_basis_points.default(0), + guild_salt: channelThreadsConfigFields.guild_salt.default(DEFAULT_CHANNEL_THREADS_GUILD_SALT), + enabled_guild_ids: channelThreadsConfigFields.enabled_guild_ids.default([]), + disabled_guild_ids: channelThreadsConfigFields.disabled_guild_ids.default([]), + user_basis_points: channelThreadsConfigFields.user_basis_points.default(0), + user_salt: channelThreadsConfigFields.user_salt.default(DEFAULT_CHANNEL_THREADS_USER_SALT), + included_user_ids: channelThreadsConfigFields.included_user_ids.default([]), + excluded_user_ids: channelThreadsConfigFields.excluded_user_ids.default([]), +}); + +export type ChannelThreadsConfig = z.infer; + +export const DEFAULT_CHANNEL_THREADS_CONFIG: ChannelThreadsConfig = ChannelThreadsConfigSchema.parse({}); + +export const ChannelThreadsConfigUpdateRequest = z + .object(channelThreadsConfigFields) + .omit({config_version: true, ever_enabled: true}) + .partial(); + +export type ChannelThreadsConfigUpdateRequest = z.infer; + +export const ChannelThreadsConfigResponse = ChannelThreadsConfigSchema; + +export type ChannelThreadsConfigResponse = z.infer; + +export function applyChannelThreadsConfigUpdate( + current: ChannelThreadsConfig, + update: ChannelThreadsConfigUpdateRequest, +): ChannelThreadsConfig { + const next = ChannelThreadsConfigSchema.parse({ + ...current, + ...update, + config_version: current.config_version + 1, + }); + return {...next, ever_enabled: current.ever_enabled || next.enabled}; +} + +export interface CompiledChannelThreadsConfig { + config: ChannelThreadsConfig; + enabledGuildIds: ReadonlySet; + disabledGuildIds: ReadonlySet; + includedUserIds: ReadonlySet; + excludedUserIds: ReadonlySet; +} + +export function compileChannelThreadsConfig(config: ChannelThreadsConfig): CompiledChannelThreadsConfig { + return { + config, + enabledGuildIds: new Set(config.enabled_guild_ids), + disabledGuildIds: new Set(config.disabled_guild_ids), + includedUserIds: new Set(config.included_user_ids), + excludedUserIds: new Set(config.excluded_user_ids), + }; +} + +export const DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG: CompiledChannelThreadsConfig = + compileChannelThreadsConfig(DEFAULT_CHANNEL_THREADS_CONFIG); + +export function channelThreadsGuildActive(compiled: CompiledChannelThreadsConfig, guildId: string): boolean { + const {config} = compiled; + if (!config.enabled) return false; + if (compiled.disabledGuildIds.has(guildId)) return false; + if (compiled.enabledGuildIds.has(guildId)) return true; + return config.guild_basis_points > 0 && experimentBucket(guildId, config.guild_salt) < config.guild_basis_points; +} + +export function channelThreadsUserExcluded(compiled: CompiledChannelThreadsConfig, userId: string): boolean { + return compiled.excludedUserIds.has(userId); +} + +export function channelThreadsUserActive(compiled: CompiledChannelThreadsConfig, userId: string): boolean { + const {config} = compiled; + if (!config.enabled) return false; + if (compiled.excludedUserIds.has(userId)) return false; + if (compiled.includedUserIds.has(userId)) return true; + return config.user_basis_points > 0 && experimentBucket(userId, config.user_salt) < config.user_basis_points; +} + +function sameIds(a: ReadonlyArray, b: ReadonlyArray): boolean { + const left = new Set(a); + const right = new Set(b); + return left.size === right.size && [...right].every((id) => left.has(id)); +} + +export function channelThreadsGuildFieldsChanged(a: ChannelThreadsConfig, b: ChannelThreadsConfig): boolean { + return ( + a.enabled !== b.enabled || + a.guild_basis_points !== b.guild_basis_points || + a.guild_salt !== b.guild_salt || + !sameIds(a.enabled_guild_ids, b.enabled_guild_ids) || + !sameIds(a.disabled_guild_ids, b.disabled_guild_ids) + ); +} + +export function channelThreadsUserFieldsChanged(a: ChannelThreadsConfig, b: ChannelThreadsConfig): boolean { + return ( + a.enabled !== b.enabled || + a.user_basis_points !== b.user_basis_points || + a.user_salt !== b.user_salt || + !sameIds(a.included_user_ids, b.included_user_ids) || + !sameIds(a.excluded_user_ids, b.excluded_user_ids) + ); +} + +export const ChannelThreadsAssignmentResponse = z.object({ + active: z.literal(true), + config_version: z.number().int().min(0), +}); + +export type ChannelThreadsAssignmentResponse = z.infer; + +export function resolveChannelThreadsAssignment( + compiled: CompiledChannelThreadsConfig, + userId: string, +): ChannelThreadsAssignmentResponse | undefined { + if (!channelThreadsUserActive(compiled, userId)) return undefined; + return {active: true, config_version: compiled.config.config_version}; +} diff --git a/packages/schema/src/domains/channel/ChannelRequestSchemas.ts b/packages/schema/src/domains/channel/ChannelRequestSchemas.ts index e6ebb91d9..f6659e9a9 100644 --- a/packages/schema/src/domains/channel/ChannelRequestSchemas.ts +++ b/packages/schema/src/domains/channel/ChannelRequestSchemas.ts @@ -18,7 +18,19 @@ import { VOICE_CHANNEL_USER_LIMIT_MAX, VOICE_CHANNEL_USER_LIMIT_MIN, } from '@fluxer/constants/src/LimitConstants'; +import { + THREAD_NAME_MAX_LENGTH, + THREAD_NAME_MIN_LENGTH, + THREAD_RATE_LIMIT_PER_USER_MAX, +} from '@fluxer/constants/src/ThreadConstants'; import {ChannelNicknameOverrides} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import { + ForumChannelRequestFields, + ForumLayoutSchema, + ThreadAppliedTagsRequestFields, + ThreadParentDefaultsRequestFields, +} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas'; +import {ThreadAutoArchiveDurationSchema} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; import {ReadStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas'; import {ChannelOverwriteTypeSchema, GeneralChannelNameType} from '@fluxer/schema/src/primitives/ChannelValidators'; import {base64LengthForBytes, createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators'; @@ -33,6 +45,7 @@ import { UnsignedInt64Type, } from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {URLType} from '@fluxer/schema/src/primitives/UrlValidators'; +import {withSchemaMetadata} from '@fluxer/schema/src/SchemaMetadata'; import {z} from 'zod'; const ChannelOverwriteRequest = z.object({ @@ -161,7 +174,34 @@ const ChannelCreateLinkRequest = ChannelCreateCommon.extend({ name: GeneralChannelNameType.describe('The name of the channel'), }); -export const ChannelCreateRequest = z.discriminatedUnion('type', [ +const EXPERIMENT = {experiment: 'channel_threads'} as const; + +const ChannelCreateThreadParentTextRequest = ChannelCreateTextRequest.extend(ThreadParentDefaultsRequestFields); + +const ChannelCreateThreadParentAnnouncementRequest = ChannelCreateAnnouncementRequest.extend( + ThreadParentDefaultsRequestFields, +); + +const ChannelCreateForumRequest = withSchemaMetadata( + ChannelCreateCommon.extend({ + type: createNamedLiteral(ChannelTypes.GUILD_FORUM, 'GUILD_FORUM', 'Channel type (forum channel)'), + name: GeneralChannelNameType.describe('The name of the channel'), + ...ForumChannelRequestFields, + default_forum_layout: ForumLayoutSchema.nullish(), + }), + EXPERIMENT, +); + +const ChannelCreateMediaRequest = withSchemaMetadata( + ChannelCreateCommon.extend({ + type: createNamedLiteral(ChannelTypes.GUILD_MEDIA, 'GUILD_MEDIA', 'Channel type (media channel)'), + name: GeneralChannelNameType.describe('The name of the channel'), + ...ForumChannelRequestFields, + }), + EXPERIMENT, +); + +export const ChannelCreateControlRequest = z.discriminatedUnion('type', [ ChannelCreateTextRequest, ChannelCreateAnnouncementRequest, ChannelCreateVoiceRequest, @@ -169,6 +209,16 @@ export const ChannelCreateRequest = z.discriminatedUnion('type', [ ChannelCreateLinkRequest, ]); +export const ChannelCreateRequest = z.discriminatedUnion('type', [ + ChannelCreateThreadParentTextRequest, + ChannelCreateThreadParentAnnouncementRequest, + ChannelCreateVoiceRequest, + ChannelCreateCategoryRequest, + ChannelCreateLinkRequest, + ChannelCreateForumRequest, + ChannelCreateMediaRequest, +]); + export type ChannelCreateRequest = z.infer; const ChannelUpdateTextRequest = ChannelUpdateCommon.extend({ @@ -210,13 +260,54 @@ const ChannelUpdateGroupDmRequest = z.object({ nicks: ChannelNicknameOverrides.nullish().describe('Custom nicknames for users in this group DM'), }); -export const ChannelUpdateRequest = z.discriminatedUnion('type', [ +const ChannelUpdateThreadFields = { + name: createStringType(THREAD_NAME_MIN_LENGTH, THREAD_NAME_MAX_LENGTH) + .optional() + .describe('The name of the thread (1-100 characters)'), + archived: z.boolean().nullish().describe('Whether the thread is archived'), + auto_archive_duration: ThreadAutoArchiveDurationSchema.optional(), + locked: z.boolean().nullish().describe('Whether only moderators can unarchive the thread'), + rate_limit_per_user: Int32Type.max(THREAD_RATE_LIMIT_PER_USER_MAX) + .nullish() + .describe('Seconds a user has to wait before sending another message (0-21600)'), + flags: Int32Type.optional().describe('Channel flags'), +}; + +const ChannelUpdatePublicThreadRequest = z.object({ + type: createNamedLiteral(ChannelTypes.PUBLIC_THREAD, 'PUBLIC_THREAD', 'Channel type (public thread)'), + ...ChannelUpdateThreadFields, + ...ThreadAppliedTagsRequestFields, +}); + +const ChannelUpdateAnnouncementThreadRequest = z.object({ + type: createNamedLiteral( + ChannelTypes.ANNOUNCEMENT_THREAD, + 'ANNOUNCEMENT_THREAD', + 'Channel type (announcement thread)', + ), + ...ChannelUpdateThreadFields, +}); + +const ChannelUpdatePrivateThreadRequest = z.object({ + type: createNamedLiteral(ChannelTypes.PRIVATE_THREAD, 'PRIVATE_THREAD', 'Channel type (private thread)'), + ...ChannelUpdateThreadFields, + invitable: z.boolean().nullish().describe('Whether non-moderators can add other non-moderators'), +}); + +const CHANNEL_UPDATE_REQUEST_OPTIONS = [ ChannelUpdateTextRequest, ChannelUpdateAnnouncementRequest, ChannelUpdateVoiceRequest, ChannelUpdateCategoryRequest, ChannelUpdateLinkRequest, ChannelUpdateGroupDmRequest, +] as const; + +export const ChannelUpdateRequest = z.discriminatedUnion('type', [ + ...CHANNEL_UPDATE_REQUEST_OPTIONS, + ChannelUpdateAnnouncementThreadRequest, + ChannelUpdatePublicThreadRequest, + ChannelUpdatePrivateThreadRequest, ]); export type ChannelUpdateRequest = z.infer; @@ -228,14 +319,91 @@ const ChannelTypeConversionField = z const CONVERTIBLE_UPDATE_REQUESTS = new Set([ChannelUpdateTextRequest, ChannelUpdateAnnouncementRequest]); -export const ChannelUpdateRequestBody = z.union( - ChannelUpdateRequest.options.map((option) => { +const ChannelUpdateThreadParentTextRequest = ChannelUpdateTextRequest.extend(ThreadParentDefaultsRequestFields); + +const ChannelUpdateThreadParentAnnouncementRequest = ChannelUpdateAnnouncementRequest.extend( + ThreadParentDefaultsRequestFields, +); + +const ChannelUpdateForumRequest = ChannelUpdateCommon.extend({ + type: createNamedLiteral(ChannelTypes.GUILD_FORUM, 'GUILD_FORUM', 'Channel type (forum channel)'), + name: GeneralChannelNameType.nullish().describe('The name of the channel'), + ...ForumChannelRequestFields, + default_forum_layout: ForumLayoutSchema.nullish(), +}); + +const ChannelUpdateMediaRequest = ChannelUpdateCommon.extend({ + type: createNamedLiteral(ChannelTypes.GUILD_MEDIA, 'GUILD_MEDIA', 'Channel type (media channel)'), + name: GeneralChannelNameType.nullish().describe('The name of the channel'), + ...ForumChannelRequestFields, +}); + +export const ChannelUpdateGatedRequest = z.discriminatedUnion('type', [ + ChannelUpdateThreadParentTextRequest, + ChannelUpdateThreadParentAnnouncementRequest, + ChannelUpdateVoiceRequest, + ChannelUpdateCategoryRequest, + ChannelUpdateLinkRequest, + ChannelUpdateGroupDmRequest, + ChannelUpdateAnnouncementThreadRequest, + ChannelUpdatePublicThreadRequest, + ChannelUpdatePrivateThreadRequest, + ChannelUpdateForumRequest, + ChannelUpdateMediaRequest, +]); + +export type ChannelUpdateGatedRequest = z.infer; + +export type ChannelUpdateNonThreadRequest = + | z.infer<(typeof CHANNEL_UPDATE_REQUEST_OPTIONS)[number]> + | z.infer + | z.infer + | z.infer + | z.infer; + +export type ChannelUpdateThreadRequest = + | z.infer + | z.infer + | z.infer; + +export const ChannelUpdatePublicThreadRequestBody = withSchemaMetadata( + ChannelUpdatePublicThreadRequest.omit({type: true}), + {experiment: 'channel_threads'}, +); + +export const ChannelUpdatePrivateThreadRequestBody = withSchemaMetadata( + ChannelUpdatePrivateThreadRequest.omit({type: true}), + {experiment: 'channel_threads'}, +); + +export const ChannelUpdateThreadParentRequestBody = withSchemaMetadata( + z.object(ThreadParentDefaultsRequestFields), + EXPERIMENT, +); + +export const ChannelUpdateForumRequestBody = withSchemaMetadata( + ChannelUpdateForumRequest.omit({type: true}), + EXPERIMENT, +); + +export const ChannelUpdateMediaRequestBody = withSchemaMetadata( + ChannelUpdateMediaRequest.omit({type: true}), + EXPERIMENT, +); + +export const ChannelUpdateRequestBody = z.union([ + ...CHANNEL_UPDATE_REQUEST_OPTIONS.map((option) => { const {type, ...shape} = option.shape; return CONVERTIBLE_UPDATE_REQUESTS.has(option) ? z.object({...shape, type: ChannelTypeConversionField}) : z.object(shape); }), -); + ChannelUpdatePublicThreadRequestBody, + ChannelUpdatePrivateThreadRequestBody, + ChannelUpdateThreadParentRequestBody, + ChannelUpdateForumRequestBody, + ChannelUpdateMediaRequestBody, +]); export const PermissionOverwriteCreateRequest = z.object({ type: ChannelOverwriteTypeSchema.describe('The type of overwrite (0 = role, 1 = member)'), diff --git a/packages/schema/src/domains/channel/ChannelSchemas.ts b/packages/schema/src/domains/channel/ChannelSchemas.ts index b73f91952..d4a4677fa 100644 --- a/packages/schema/src/domains/channel/ChannelSchemas.ts +++ b/packages/schema/src/domains/channel/ChannelSchemas.ts @@ -2,6 +2,7 @@ import {CONTENT_WARNING_TEXT_MAX_LENGTH} from '@fluxer/constants/src/GuildConstants'; import {MAX_GROUP_DM_OTHER_RECIPIENTS, MAX_GROUP_DM_RECIPIENTS} from '@fluxer/constants/src/LimitConstants'; +import {ThreadChannelFields, ThreadParentChannelFields} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; import {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {ChannelOverwriteTypeSchema, ChannelTypeSchema} from '@fluxer/schema/src/primitives/ChannelValidators'; import {ContentWarningLevelSchema} from '@fluxer/schema/src/primitives/GuildValidators'; @@ -101,10 +102,16 @@ export const ChannelResponse = z.object({ .record(z.string(), createStringType(1, 32)) .optional() .describe('Custom nicknames for users in this channel (for group DMs)'), + ...ThreadChannelFields, + ...ThreadParentChannelFields, }); export type ChannelResponse = z.infer; +export const ThreadChannelResponse = ChannelResponse.extend(ThreadChannelFields); + +export type ThreadChannelResponse = z.infer; + export const ChannelNicknameOverrides = z .record( z.string().describe('User ID'), diff --git a/packages/schema/src/domains/channel/ForumRequestSchemas.ts b/packages/schema/src/domains/channel/ForumRequestSchemas.ts new file mode 100644 index 000000000..98e197e59 --- /dev/null +++ b/packages/schema/src/domains/channel/ForumRequestSchemas.ts @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + FORUM_TAG_NAME_MAX_LENGTH, + FORUM_TOPIC_MAX_LENGTH, + ForumLayoutTypes, + ForumSortOrderTypes, + MAX_APPLIED_TAGS_PER_THREAD, + POST_DATA_MAX_IDS, + THREAD_NAME_MAX_LENGTH, + THREAD_NAME_MIN_LENGTH, + THREAD_RATE_LIMIT_PER_USER_MAX, + THREAD_SEARCH_MAX_LIMIT, + THREAD_SEARCH_MAX_OFFSET, + THREAD_SEARCH_MAX_TAGS, + THREAD_SEARCH_MIN_LIMIT, + THREAD_SEARCH_NAME_MAX_LENGTH, +} from '@fluxer/constants/src/ThreadConstants'; +import {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import { + StartThreadRequest, + ThreadAutoArchiveDurationSchema, +} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; +import {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; +import {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; +import {MessageRequestSchema} from '@fluxer/schema/src/domains/message/MessageRequestSchemas'; +import {MessageResponseSchema} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {createQueryIntegerType} from '@fluxer/schema/src/primitives/QueryValidators'; +import { + createInt32EnumType, + createStringType, + Int32Type, + SnowflakeStringType, + SnowflakeType, +} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {withSchemaMetadata} from '@fluxer/schema/src/SchemaMetadata'; +import {z} from 'zod'; + +const EXPERIMENT = {experiment: 'channel_threads'} as const; +const EMOJI_NAME_MAX_LENGTH = 64; +const LOCATION_MAX_LENGTH = 100; + +export const ForumTagRequest = withSchemaMetadata( + z.object({ + name: createStringType(1, FORUM_TAG_NAME_MAX_LENGTH).describe('The name of the tag (1-50 characters)'), + moderated: z.boolean().optional().describe('Whether only moderators can add or remove this tag'), + emoji_id: SnowflakeType.nullish().describe('The ID of a custom guild emoji'), + emoji_name: createStringType(1, EMOJI_NAME_MAX_LENGTH).nullish().describe('The unicode character of the emoji'), + }), + EXPERIMENT, +); + +export type ForumTagRequest = z.infer; + +export const ForumTagUpdateRequest = withSchemaMetadata( + ForumTagRequest.extend({ + id: SnowflakeType.optional().describe('The ID of an existing tag to keep'), + }), + EXPERIMENT, +); + +export type ForumTagUpdateRequest = z.infer; + +export const DefaultReactionEmojiRequest = withSchemaMetadata( + z.object({ + emoji_id: SnowflakeType.nullish().describe('The ID of a custom guild emoji'), + emoji_name: createStringType(1, EMOJI_NAME_MAX_LENGTH).nullish().describe('The unicode character of the emoji'), + }), + EXPERIMENT, +); + +export type DefaultReactionEmojiRequest = z.infer; + +export const ForumSortOrderSchema = createInt32EnumType( + [ + [ForumSortOrderTypes.LATEST_ACTIVITY, 'LATEST_ACTIVITY', 'Sort posts by latest activity'], + [ForumSortOrderTypes.CREATION_TIME, 'CREATION_TIME', 'Sort posts by creation time'], + ], + 'The default sort order of posts', + 'ForumSortOrderType', +); + +export const ForumLayoutSchema = createInt32EnumType( + [ + [ForumLayoutTypes.DEFAULT, 'DEFAULT', 'No default layout'], + [ForumLayoutTypes.LIST, 'LIST', 'Display posts as a list'], + [ForumLayoutTypes.GRID, 'GRID', 'Display posts as a collection of tiles'], + ], + 'The default layout of a forum channel', + 'ForumLayoutType', +); + +export const ForumTagSettingSchema = z + .enum(['match_some', 'match_all']) + .describe('How posts are filtered when searching by several tags'); + +export const ThreadParentDefaultsRequestFields = { + default_auto_archive_duration: ThreadAutoArchiveDurationSchema.nullish().describe( + 'Default auto archive duration in minutes for new threads', + ), + default_thread_rate_limit_per_user: Int32Type.max(THREAD_RATE_LIMIT_PER_USER_MAX) + .nullish() + .describe('Slowmode in seconds copied onto new threads (0-21600)'), +}; + +export const ForumChannelRequestFields = { + ...ThreadParentDefaultsRequestFields, + topic: createStringType(1, FORUM_TOPIC_MAX_LENGTH) + .nullish() + .describe(`The guidelines of the channel (1-${FORUM_TOPIC_MAX_LENGTH} characters)`), + available_tags: z.array(ForumTagUpdateRequest).optional().describe('Tags that can be applied to posts (max 20)'), + default_reaction_emoji: DefaultReactionEmojiRequest.nullish().describe('Default reaction shown on posts'), + default_sort_order: ForumSortOrderSchema.nullish(), + default_tag_setting: ForumTagSettingSchema.nullish(), + flags: Int32Type.optional().describe('Channel flags'), +}; + +const ThreadNameType = createStringType(THREAD_NAME_MIN_LENGTH, THREAD_NAME_MAX_LENGTH).describe( + 'The name of the post (1-100 characters)', +); + +export const ForumThreadMessageRequest = withSchemaMetadata( + MessageRequestSchema.omit({message_reference: true, favorite_meme_id: true, tts: true, nonce: true}), + EXPERIMENT, +); + +export type ForumThreadMessageRequest = z.infer; + +const AppliedTagsType = z + .array(SnowflakeType) + .max(MAX_APPLIED_TAGS_PER_THREAD) + .describe('IDs of the tags applied to the post (max 5)'); + +export const StartForumThreadRequest = withSchemaMetadata( + z.object({ + name: ThreadNameType, + type: Int32Type.optional().describe('The type of thread, which is always a public thread in a forum'), + auto_archive_duration: ThreadAutoArchiveDurationSchema.optional(), + rate_limit_per_user: Int32Type.max(THREAD_RATE_LIMIT_PER_USER_MAX) + .optional() + .describe('Seconds a user has to wait before sending another message (0-21600)'), + applied_tags: AppliedTagsType.optional(), + message: ForumThreadMessageRequest.describe('The first message of the post'), + location: z.string().max(LOCATION_MAX_LENGTH).optional().describe('Accepted and ignored'), + }), + EXPERIMENT, +); + +export type StartForumThreadRequest = z.infer; + +export const StartThreadRequestBody = z.union([StartThreadRequest, StartForumThreadRequest]); + +export const StartThreadMultipartRequest = withSchemaMetadata( + z.object({ + payload_json: z.string().describe('The JSON-encoded request body'), + 'files[0]': z + .file() + .optional() + .describe( + 'A file for the first message of a post, referenced by attachment id 0. Further files go in files[1], files[2] and so on', + ), + }), + EXPERIMENT, +); + +export const StartForumThreadResponse = withSchemaMetadata( + ThreadChannelResponse.extend({ + message: MessageResponseSchema.optional().describe('The first message of the post'), + }), + EXPERIMENT, +); + +export type StartForumThreadResponse = z.infer; + +export const ThreadAppliedTagsRequestFields = { + applied_tags: AppliedTagsType.optional(), +}; + +export const ThreadPostDataRequest = withSchemaMetadata( + z.object({ + thread_ids: z + .array(SnowflakeType) + .min(1) + .max(POST_DATA_MAX_IDS) + .describe(`The IDs of the posts to get data for (max ${POST_DATA_MAX_IDS})`), + }), + EXPERIMENT, +); + +export type ThreadPostDataRequest = z.infer; + +export const ThreadPostDataEntryResponse = z.object({ + owner: GuildMemberResponse.nullable().describe('The guild member who created the post'), + first_message: MessageResponseSchema.nullable().describe('The first message of the post'), +}); + +export const ThreadPostDataResponse = withSchemaMetadata( + z.object({ + threads: z + .record(SnowflakeStringType, ThreadPostDataEntryResponse) + .describe('A mapping of post IDs to their post data'), + }), + EXPERIMENT, +); + +export type ThreadPostDataResponse = z.infer; + +const TagQueryType = z.preprocess( + (value) => (value === undefined || Array.isArray(value) ? value : [value]), + z.array(SnowflakeType).max(THREAD_SEARCH_MAX_TAGS), +); + +const THREAD_SEARCH_ARCHIVED_VALUES = ['true', 'false', 'True', 'False', '1', '0']; + +export const ThreadSearchSortBySchema = z + .enum(['last_message_time', 'archive_time', 'relevance', 'creation_time']) + .describe('The sorting algorithm to use'); + +export const ThreadSearchSortOrderSchema = z.enum(['asc', 'desc']).describe('The direction to sort'); + +export const ThreadSearchQuery = withSchemaMetadata( + z.object({ + name: z + .string() + .max(THREAD_SEARCH_NAME_MAX_LENGTH) + .optional() + .describe('Text to look for in thread names (max 100 characters)'), + slop: createQueryIntegerType({defaultValue: 2, minValue: 0, maxValue: 100}).describe( + 'Accepted for compatibility and ignored (max 100, default 2)', + ), + tag: TagQueryType.optional().describe('Tag IDs to filter by (max 20)'), + tag_setting: ForumTagSettingSchema.optional(), + archived: z + .string() + .optional() + .refine((value) => value === undefined || THREAD_SEARCH_ARCHIVED_VALUES.includes(value)) + .transform((value) => (value === undefined ? undefined : value === 'true' || value === 'True' || value === '1')) + .describe('Whether to return only archived or only active threads (default both)'), + sort_by: ThreadSearchSortBySchema.default('last_message_time'), + sort_order: ThreadSearchSortOrderSchema.default('desc'), + limit: createQueryIntegerType({ + defaultValue: THREAD_SEARCH_MAX_LIMIT, + minValue: THREAD_SEARCH_MIN_LIMIT, + maxValue: THREAD_SEARCH_MAX_LIMIT, + }).describe('Max number of threads to return (1-25, default 25)'), + offset: createQueryIntegerType({defaultValue: 0, minValue: 0, maxValue: THREAD_SEARCH_MAX_OFFSET}).describe( + 'Number of threads to skip (max 9975)', + ), + max_id: SnowflakeType.optional().describe('Get threads before this thread ID'), + min_id: SnowflakeType.optional().describe('Get threads after this thread ID'), + }), + EXPERIMENT, +); + +export type ThreadSearchQuery = z.infer; + +export const ThreadSearchResponse = withSchemaMetadata( + z.object({ + threads: z.array(ThreadChannelResponse).describe('The threads that match the search'), + members: z.array(ThreadMemberResponse).describe('A thread member object for each returned thread the user joined'), + has_more: z.boolean().describe('Whether more threads could be returned by a later request'), + total_results: Int32Type.describe('The total number of threads that match the search'), + first_messages: z + .array(MessageResponseSchema) + .optional() + .describe('The first message of each returned post, in forum and media channels only'), + }), + EXPERIMENT, +); + +export type ThreadSearchResponse = z.infer; + +export const SearchIndexNotReadyResponse = withSchemaMetadata( + z.object({ + code: z.literal('SEARCH_INDEX_NOT_READY').describe('Machine-readable code of the response'), + message: z.string().describe('Human-readable description of the response'), + documents_indexed: Int32Type.describe('Always 0 while the index is being built'), + retry_after: z.number().describe('Seconds to wait before retrying the search'), + }), + EXPERIMENT, +); + +export type SearchIndexNotReadyResponse = z.infer; + +export const ThreadSearchResult = withSchemaMetadata( + z.union([ThreadSearchResponse, SearchIndexNotReadyResponse]), + EXPERIMENT, +); + +export type ThreadSearchResult = z.infer; + +export const ChannelIdTagIdParam = z.object({ + channel_id: SnowflakeType.describe('The ID of the channel'), + tag_id: SnowflakeType.describe('The ID of the forum tag'), +}); diff --git a/packages/schema/src/domains/channel/GuildChannelOrdering.ts b/packages/schema/src/domains/channel/GuildChannelOrdering.ts index b28d90fd3..e30a2ca6c 100644 --- a/packages/schema/src/domains/channel/GuildChannelOrdering.ts +++ b/packages/schema/src/domains/channel/GuildChannelOrdering.ts @@ -51,9 +51,12 @@ function channelLayoutRank(channel: {type: number}): number { if ( channel.type === ChannelTypes.GUILD_TEXT || channel.type === ChannelTypes.GUILD_ANNOUNCEMENT || - channel.type === ChannelTypes.GUILD_LINK - ) + channel.type === ChannelTypes.GUILD_LINK || + channel.type === ChannelTypes.GUILD_FORUM || + channel.type === ChannelTypes.GUILD_MEDIA + ) { return 0; + } if (channel.type === ChannelTypes.GUILD_VOICE) return 1; return 2; } diff --git a/packages/schema/src/domains/channel/ThreadRequestSchemas.ts b/packages/schema/src/domains/channel/ThreadRequestSchemas.ts new file mode 100644 index 000000000..4eff91c7f --- /dev/null +++ b/packages/schema/src/domains/channel/ThreadRequestSchemas.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import { + THREAD_ARCHIVED_LIST_DEFAULT_LIMIT, + THREAD_ARCHIVED_LIST_MAX_LIMIT, + THREAD_ARCHIVED_LIST_MIN_LIMIT, + THREAD_NAME_MAX_LENGTH, + THREAD_NAME_MIN_LENGTH, + THREAD_RATE_LIMIT_PER_USER_MAX, +} from '@fluxer/constants/src/ThreadConstants'; +import {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; +import {IsoTimestampStringType} from '@fluxer/schema/src/primitives/DateValidators'; +import {createQueryIntegerType, QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators'; +import { + createInt32EnumType, + createStringType, + Int32Type, + SnowflakeType, +} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {z} from 'zod'; + +const THREAD_MEMBERS_LIST_MAX_LIMIT = 100; +const LOCATION_MAX_LENGTH = 100; + +export const ThreadAutoArchiveDurationSchema = createInt32EnumType( + [ + [60, 'ONE_HOUR', 'One hour'], + [1440, 'ONE_DAY', 'One day'], + [4320, 'THREE_DAYS', 'Three days'], + [10080, 'ONE_WEEK', 'One week'], + ], + 'Minutes of inactivity before the thread stops showing in the channel list', + 'ThreadAutoArchiveDuration', +); + +const ThreadNameType = createStringType(THREAD_NAME_MIN_LENGTH, THREAD_NAME_MAX_LENGTH).describe( + 'The name of the thread (1-100 characters)', +); + +const ThreadRateLimitPerUserType = Int32Type.max(THREAD_RATE_LIMIT_PER_USER_MAX).describe( + 'Seconds a user has to wait before sending another message (0-21600)', +); + +const LocationType = z.string().max(LOCATION_MAX_LENGTH).optional().describe('Accepted and ignored'); + +export const StartThreadFromMessageRequest = z.object({ + name: ThreadNameType, + auto_archive_duration: ThreadAutoArchiveDurationSchema.optional(), + rate_limit_per_user: ThreadRateLimitPerUserType.optional(), + location: LocationType, +}); + +export type StartThreadFromMessageRequest = z.infer; + +export const StartThreadRequest = z.object({ + name: ThreadNameType, + type: createInt32EnumType( + [ + [ChannelTypes.ANNOUNCEMENT_THREAD, 'ANNOUNCEMENT_THREAD', 'A thread in an announcement channel'], + [ChannelTypes.PUBLIC_THREAD, 'PUBLIC_THREAD', 'A public thread'], + [ChannelTypes.PRIVATE_THREAD, 'PRIVATE_THREAD', 'A private thread'], + ], + 'The type of thread to create', + 'ThreadChannelType', + ), + auto_archive_duration: ThreadAutoArchiveDurationSchema.optional(), + rate_limit_per_user: ThreadRateLimitPerUserType.optional(), + invitable: z.boolean().optional().describe('Whether non-moderators can add other non-moderators (private threads)'), + location: LocationType, +}); + +export type StartThreadRequest = z.infer; + +export const ThreadLocationQuery = z.object({ + location: LocationType, +}); + +export const ThreadMembersListQuery = z.object({ + with_member: QueryBooleanType.describe('Whether to include a guild member object for each thread member'), + after: SnowflakeType.optional().describe('Get thread members after this user ID'), + limit: createQueryIntegerType({ + defaultValue: THREAD_MEMBERS_LIST_MAX_LIMIT, + minValue: 1, + maxValue: THREAD_MEMBERS_LIST_MAX_LIMIT, + }).describe('Max number of thread members to return (1-100, default 100)'), +}); + +export type ThreadMembersListQuery = z.infer; + +export const ThreadMemberGetQuery = z.object({ + with_member: QueryBooleanType.describe('Whether to include a guild member object for the thread member'), +}); + +const archivedListLimit = createQueryIntegerType({ + defaultValue: THREAD_ARCHIVED_LIST_DEFAULT_LIMIT, + minValue: THREAD_ARCHIVED_LIST_MIN_LIMIT, + maxValue: THREAD_ARCHIVED_LIST_MAX_LIMIT, +}).describe('Max number of threads to return (2-100, default 50)'); + +export const ArchivedThreadsQuery = z.object({ + before: IsoTimestampStringType.optional().describe('Get threads archived before this timestamp'), + limit: archivedListLimit, +}); + +export type ArchivedThreadsQuery = z.infer; + +export const JoinedArchivedThreadsQuery = z.object({ + before: SnowflakeType.optional().describe('Get threads before this thread ID'), + limit: archivedListLimit, +}); + +export type JoinedArchivedThreadsQuery = z.infer; + +export {ThreadChannelResponse}; + +export const ActiveThreadsResponse = z.object({ + threads: z.array(ThreadChannelResponse).describe('The active threads'), + members: z.array(ThreadMemberResponse).describe('A thread member object for each returned thread the user joined'), +}); + +export type ActiveThreadsResponse = z.infer; + +export const ArchivedThreadsResponse = ActiveThreadsResponse.extend({ + threads: z.array(ThreadChannelResponse).describe('The archived threads'), + has_more: z.boolean().describe('Whether there are potentially more threads to fetch'), +}); + +export type ArchivedThreadsResponse = z.infer; + +export const ThreadMemberListResponse = z.array(ThreadMemberResponse); + +export type ThreadMemberListResponse = z.infer; diff --git a/packages/schema/src/domains/channel/ThreadSchemas.ts b/packages/schema/src/domains/channel/ThreadSchemas.ts new file mode 100644 index 000000000..2a1492b21 --- /dev/null +++ b/packages/schema/src/domains/channel/ThreadSchemas.ts @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + FORUM_TAG_NAME_MAX_LENGTH, + MAX_APPLIED_TAGS_PER_THREAD, + MAX_FORUM_TAGS_PER_CHANNEL, + THREAD_MEMBER_IDS_PREVIEW_SIZE, +} from '@fluxer/constants/src/ThreadConstants'; +import {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; +import {Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {z} from 'zod'; + +const ThreadMuteConfig = z + .object({ + end_time: z.string().nullable().describe('ISO8601 timestamp of when the mute expires'), + selected_time_window: Int32Type.describe('The selected mute duration in seconds'), + }) + .nullable(); + +export const ThreadMetadataResponse = z.object({ + archived: z.boolean().describe('Whether the thread is archived'), + auto_archive_duration: Int32Type.describe( + 'Minutes of inactivity before the thread stops showing in the channel list', + ), + archive_timestamp: z.iso.datetime().describe('When the archive status of the thread last changed'), + locked: z.boolean().describe('Whether only moderators can interact with the thread'), + invitable: z.boolean().optional().describe('Whether non-moderators can add other non-moderators (private threads)'), + create_timestamp: z.iso.datetime().describe('When the thread was created'), +}); + +export type ThreadMetadataResponse = z.infer; + +export const ThreadMemberResponse = z.object({ + id: SnowflakeStringType.optional().describe('The ID of the thread'), + user_id: SnowflakeStringType.optional().describe('The ID of the user'), + join_timestamp: z.iso.datetime().describe('When the user last joined the thread'), + flags: Int32Type.describe('Thread member flags'), + muted: z.boolean().optional().describe('Whether the user has muted the thread (current user only)'), + mute_config: ThreadMuteConfig.optional().describe('The mute configuration for the thread (current user only)'), + member: GuildMemberResponse.optional().describe('The guild member object for the user'), +}); + +export type ThreadMemberResponse = z.infer; + +export const ForumTagResponse = z.object({ + id: SnowflakeStringType.describe('The ID of the tag'), + name: z.string().max(FORUM_TAG_NAME_MAX_LENGTH).describe('The name of the tag'), + moderated: z.boolean().describe('Whether only moderators can add or remove this tag'), + emoji_id: SnowflakeStringType.nullable().describe('The ID of a custom guild emoji'), + emoji_name: z.string().nullable().describe('The unicode character of the emoji'), +}); + +export type ForumTagResponse = z.infer; + +export const DefaultReactionEmojiResponse = z.object({ + emoji_id: SnowflakeStringType.nullable().describe('The ID of a custom guild emoji'), + emoji_name: z.string().nullable().describe('The unicode character of the emoji'), +}); + +export type DefaultReactionEmojiResponse = z.infer; + +export const ThreadChannelFields = { + flags: Int32Type.optional().describe('Channel flags'), + thread_metadata: ThreadMetadataResponse.optional().describe('Thread-specific fields'), + applied_tags: z + .array(SnowflakeStringType) + .max(MAX_APPLIED_TAGS_PER_THREAD) + .optional() + .describe('IDs of the tags applied to a forum or media post'), + message_count: Int32Type.optional().describe('Messages in the thread, excluding the starter and deleted messages'), + total_message_sent: Int32Type.optional().describe('Messages ever sent in the thread, never decremented'), + member_count: Int32Type.optional().describe('Approximate number of thread members, capped at 50'), + member_ids_preview: z + .array(SnowflakeStringType) + .max(THREAD_MEMBER_IDS_PREVIEW_SIZE) + .optional() + .describe('IDs of the most recently joined members of a forum or media post, newest first'), + member: ThreadMemberResponse.optional().describe('The thread member object for the current user'), +}; + +export const ThreadParentChannelFields = { + flags: Int32Type.optional().describe('Channel flags'), + default_auto_archive_duration: Int32Type.nullish().describe('Default auto archive duration for new threads'), + default_thread_rate_limit_per_user: Int32Type.optional().describe('Slowmode copied onto new threads'), + available_tags: z + .array(ForumTagResponse) + .max(MAX_FORUM_TAGS_PER_CHANNEL) + .optional() + .describe('Tags that can be applied to posts'), + default_reaction_emoji: DefaultReactionEmojiResponse.nullish().describe('Default reaction for posts'), + default_sort_order: Int32Type.nullish().describe('Default sort order for posts'), + default_forum_layout: Int32Type.optional().describe('Default layout for forum posts'), + default_tag_setting: z.enum(['match_some', 'match_all']).optional().describe('Default tag search setting'), +}; + +export const ThreadParentSettingsRpcResponse = z.object({ + channel_id: SnowflakeStringType.describe('The ID of the parent channel'), + ...ThreadParentChannelFields, +}); + +export type ThreadParentSettingsRpcResponse = z.infer; + +export const ThreadMemberRpcResponse = z.object({ + id: SnowflakeStringType.describe('The ID of the thread'), + user_id: SnowflakeStringType.describe('The ID of the user'), + join_timestamp: z.iso.datetime().describe('When the user last joined the thread'), + flags: Int32Type.describe('Thread member flags'), + muted: z.boolean().optional().describe('Whether the user has muted the thread'), + mute_config: ThreadMuteConfig.optional().describe('The mute configuration for the thread'), +}); + +export type ThreadMemberRpcResponse = z.infer; diff --git a/packages/schema/src/domains/experiment/ExperimentSchemas.ts b/packages/schema/src/domains/experiment/ExperimentSchemas.ts index 8e99e669f..3bee3f602 100644 --- a/packages/schema/src/domains/experiment/ExperimentSchemas.ts +++ b/packages/schema/src/domains/experiment/ExperimentSchemas.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {ChannelThreadsAssignmentResponse} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; import { DomainMigrationAssignmentResponse, INERT_DOMAIN_MIGRATION_ASSIGNMENT, @@ -46,6 +47,7 @@ export type ExperimentDeliveryConfigResponse = z.infer; diff --git a/packages/schema/src/domains/guild/GuildAuditLogSchemas.ts b/packages/schema/src/domains/guild/GuildAuditLogSchemas.ts index 962471d44..e7ad18a6e 100644 --- a/packages/schema/src/domains/guild/GuildAuditLogSchemas.ts +++ b/packages/schema/src/domains/guild/GuildAuditLogSchemas.ts @@ -1,7 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; -import {AuditLogActionTypeSchema} from '@fluxer/schema/src/primitives/AuditLogValidators'; +import { + AuditLogActionTypeFilterSchema, + AuditLogActionTypeSchema, +} from '@fluxer/schema/src/primitives/AuditLogValidators'; import { coerceNumberFromString, Int32Type, @@ -95,6 +99,10 @@ export const GuildAuditLogListResponse = z.object({ audit_log_entries: z.array(GuildAuditLogEntryResponse).describe('Array of audit log entries'), users: z.array(UserPartialResponse).describe('Users referenced in the audit log entries'), webhooks: z.array(AuditLogWebhookResponse).describe('Webhooks referenced in the audit log entries'), + threads: z + .array(z.lazy(() => ThreadChannelResponse)) + .optional() + .describe('Threads referenced in the audit log entries, when the viewer can see threads'), }); export type GuildAuditLogListResponse = z.infer; @@ -106,9 +114,15 @@ export const GuildAuditLogListQuery = z.object({ before: SnowflakeType.optional().describe('Get entries before this audit log entry ID'), after: SnowflakeType.optional().describe('Get entries after this audit log entry ID'), user_id: SnowflakeType.optional().describe('Filter entries by the user who performed the action'), - action_type: coerceNumberFromString(AuditLogActionTypeSchema) + action_type: coerceNumberFromString(AuditLogActionTypeFilterSchema) .optional() .describe('Filter entries by the type of action'), }); export type GuildAuditLogListQuery = z.infer; + +export const GuildAuditLogListQueryWithThreads = GuildAuditLogListQuery.extend({ + action_type: coerceNumberFromString(AuditLogActionTypeSchema) + .optional() + .describe('Filter entries by the type of action'), +}); diff --git a/packages/schema/src/domains/guild/GuildResponseSchemas.ts b/packages/schema/src/domains/guild/GuildResponseSchemas.ts index 85f5c7140..fedd4a3a0 100644 --- a/packages/schema/src/domains/guild/GuildResponseSchemas.ts +++ b/packages/schema/src/domains/guild/GuildResponseSchemas.ts @@ -30,6 +30,7 @@ import { withFieldDescription, withOpenApiType, } from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {withSchemaMetadata} from '@fluxer/schema/src/SchemaMetadata'; import {z} from 'zod'; function normalizeGuildFeatures(features: Array): Array { @@ -199,6 +200,14 @@ export const GuildResponse = z.object({ approximate_presence_count: Int32Type.optional().describe( 'Approximate online member count (only when with_counts is true)', ), + threads_active: withSchemaMetadata( + z + .literal(true) + .describe( + 'Present and true when threads, forum and media channels are active in this guild for the requesting client. Only set on the current user guild list', + ), + {experiment: 'channel_threads'}, + ).optional(), }); export type GuildResponse = z.infer; diff --git a/packages/schema/src/domains/message/MessageResponseSchemas.ts b/packages/schema/src/domains/message/MessageResponseSchemas.ts index e66997da5..0dc8b8945 100644 --- a/packages/schema/src/domains/message/MessageResponseSchemas.ts +++ b/packages/schema/src/domains/message/MessageResponseSchemas.ts @@ -6,7 +6,8 @@ import { MessageFlags, MessageFlagsDescriptions, } from '@fluxer/constants/src/ChannelConstants'; -import {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import {ChannelResponse, ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import {type MessageEmbed, MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas'; import {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; @@ -50,7 +51,7 @@ export type MessageAttachmentResponse = z.infer ThreadChannelResponse) + .optional() + .describe('The thread started from this message, when the viewer can see threads'), }); type MessageBaseResponse = z.infer; @@ -209,6 +214,14 @@ export const MessageSearchResultsResponse = z.object({ hits_per_page: Int32Type.describe('The maximum number of messages returned per page'), page: Int32Type.describe('The current page number'), cursor: z.array(z.string()).optional().describe('Opaque cursor for fetching the next page of results'), + threads: z + .array(z.lazy(() => ThreadChannelResponse)) + .optional() + .describe('The threads that contain the returned messages, when the viewer can see threads'), + members: z + .array(z.lazy(() => ThreadMemberResponse)) + .optional() + .describe('A thread member object for each returned thread the current user has joined'), }); export type MessageSearchResultsResponse = z.infer; @@ -345,6 +358,7 @@ export interface Message { readonly referenced_message?: Message | null; readonly message_snapshots?: ReadonlyArray; readonly call?: MessageCall | null; + readonly thread?: ThreadChannelResponse; readonly state?: string; readonly nonce?: string; readonly blocked?: boolean; diff --git a/packages/schema/src/domains/rpc/RpcSchemas.ts b/packages/schema/src/domains/rpc/RpcSchemas.ts index c1da63a53..353306865 100644 --- a/packages/schema/src/domains/rpc/RpcSchemas.ts +++ b/packages/schema/src/domains/rpc/RpcSchemas.ts @@ -1,10 +1,16 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {RTC_REGION_ID_MAX_LENGTH, RTC_REGION_ID_MIN_LENGTH} from '@fluxer/constants/src/LimitConstants'; +import {FORUM_UNREAD_COUNT_CAP, FORUM_UNREADS_MAX_THREADS} from '@fluxer/constants/src/ThreadConstants'; +import {ChannelThreadsConfigResponse} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; import {GatewayRolloutConfigResponse} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas'; import {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas'; import {WebAuthnCredentialResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas'; import {ChannelResponse, RtcRegionResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import { + ThreadMemberRpcResponse, + ThreadParentSettingsRpcResponse, +} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; import {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas'; import {GuildEmojiResponse, GuildStickerResponse} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas'; import {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; @@ -47,6 +53,15 @@ const ReadStateResponse = z.object({ last_message_id: SnowflakeStringType.nullish().describe('ID of the last read message'), last_pin_timestamp: z.string().nullish().describe('Timestamp of the last pinned message'), version: UnsignedInt64StringType.optional().describe('Read-state version for ordering updates as a decimal uint64'), + flags: z.number().int().min(0).optional().describe('Server-stamped read state flags'), +}); + +const RPC_THREAD_MEMBERS_PAGE_MAX = 1000; +const RPC_THREAD_MEMBERS_THREADS_MAX = 100; + +const ThreadGateRpcResponse = z.object({ + active: z.boolean().describe('Whether the channel threads experiment is active for the guild'), + config_version: z.number().int().min(0).describe('Channel threads config version this decision was made on'), }); export const RpcRequest = z.discriminatedUnion('type', [ @@ -57,6 +72,7 @@ export const RpcRequest = z.discriminatedUnion('type', [ ip: createStringType(1, 45).optional().describe('Client IP address'), latitude: createStringType(1, 32).optional().describe('Client latitude for region selection'), longitude: createStringType(1, 32).optional().describe('Client longitude for region selection'), + thread_channels_capable: z.boolean().optional().describe('Whether the session can handle thread channels'), }), z.object({ type: z.literal('guild_collection').describe('Request type for fetching a single guild collection chunk'), @@ -223,6 +239,50 @@ export const RpcRequest = z.discriminatedUnion('type', [ .literal('get_push_service_delivery_config') .describe('Request type for fetching push service delivery configuration'), }), + z.object({ + type: z.literal('get_channel_threads_config').describe('Request type for fetching channel threads configuration'), + }), + z.object({ + type: z.literal('get_thread_memberships').describe('Request type for fetching the thread memberships of a user'), + guild_id: SnowflakeType.describe('ID of the guild'), + user_id: SnowflakeType.describe('ID of the user'), + }), + z.object({ + type: z.literal('list_thread_members').describe('Request type for listing the members of a thread'), + guild_id: SnowflakeType.describe('ID of the guild'), + thread_ids: z + .array(SnowflakeType) + .min(1) + .max(RPC_THREAD_MEMBERS_THREADS_MAX) + .describe('IDs of the threads, walked in order'), + limit: z.number().int().min(1).max(RPC_THREAD_MEMBERS_PAGE_MAX).optional().describe('Maximum members to return'), + after_user_id: SnowflakeType.optional().describe('Member cursor within the first thread'), + }), + z.object({ + type: z.literal('forum_unreads').describe('Request type for counting unread messages in forum posts'), + guild_id: SnowflakeType.describe('ID of the guild'), + channel_id: SnowflakeType.describe('ID of the forum or media channel'), + user_id: SnowflakeType.describe('ID of the requesting user'), + threads: z + .array( + z.object({ + thread_id: SnowflakeType.describe('ID of the post'), + ack_message_id: SnowflakeType.optional().describe('Last message the user has read in the post'), + }), + ) + .min(1) + .max(FORUM_UNREADS_MAX_THREADS) + .describe('Posts to count'), + }), + z.object({ + type: z.literal('guild_thread_flip_data').describe('Request type for re-evaluating the thread gate of a guild'), + guild_id: SnowflakeType.describe('ID of the guild'), + config_version: z.number().int().min(0).describe('Channel threads config version known to the gateway'), + paged_members: z + .boolean() + .optional() + .describe('Omit thread members so the gateway pages them through list_thread_members'), + }), ]); export type RpcRequest = z.infer; @@ -325,6 +385,12 @@ export const RpcResponseGuildCollectionData = z.object({ stickers: z.array(GuildStickerResponse).nullish().describe('List of custom stickers in the guild'), members: z.array(GuildMemberResponse).nullish().describe('List of guild members in this chunk'), voice_states: z.array(VoiceStateResponse).nullish().describe('List of guild voice states in this chunk'), + thread_gate: ThreadGateRpcResponse.optional().describe('Channel threads gate decision for the guild'), + thread_tainted: z.boolean().optional().describe('Whether the guild has ever held thread data'), + thread_only_channels: z + .array(ChannelResponse) + .optional() + .describe('Forum and media channels of the guild, merged into channels when active'), has_more: z.boolean().describe('Whether more data is available for this collection'), next_after_user_id: SnowflakeStringType.nullish().describe('Cursor for the next member chunk'), }); @@ -384,6 +450,10 @@ export const RpcResponse = z.discriminatedUnion('type', [ platform: z.string().describe('Push provider platform for this subscription'), app_id: z.string().nullable().describe('Client app channel or bundle mapping identifier'), provider_environment: z.string().nullable().describe('Push provider environment'), + thread_channels: z + .literal(true) + .optional() + .describe('Present when the subscribing client can open thread channels from a push'), }), ), ) @@ -546,6 +616,73 @@ export const RpcResponse = z.discriminatedUnion('type', [ }) .describe('Push service delivery config result'), }), + z.object({ + type: z.literal('get_channel_threads_config').describe('Response type for channel threads configuration'), + data: z + .object({ + config: ChannelThreadsConfigResponse.describe('Channel threads configuration'), + }) + .describe('Channel threads config result'), + }), + z.object({ + type: z.literal('get_thread_memberships').describe('Response type for the thread memberships of a user'), + data: z + .object({ + members: z.array(ThreadMemberRpcResponse).describe('Thread memberships of the user in the guild'), + }) + .describe('Thread memberships result'), + }), + z.object({ + type: z.literal('list_thread_members').describe('Response type for a page of thread members'), + data: z + .object({ + members: z.array(ThreadMemberRpcResponse).describe('Thread members in this page'), + has_more: z.boolean().describe('Whether more members are available'), + next_thread_id: SnowflakeStringType.nullish().describe('Thread the next page starts in'), + next_after_user_id: SnowflakeStringType.nullish().describe('Member cursor within that thread'), + }) + .describe('Thread members result'), + }), + z.object({ + type: z.literal('forum_unreads').describe('Response type for forum post unread counts'), + data: z + .object({ + threads: z + .array( + z.object({ + thread_id: SnowflakeStringType.describe('ID of the post'), + count: z + .number() + .int() + .min(0) + .max(FORUM_UNREAD_COUNT_CAP) + .optional() + .describe('Unread messages after the acknowledged message, capped'), + missing: z.boolean().optional().describe('Whether the post has no acknowledged message'), + }), + ) + .describe('Unread counts of the posts that belong to the channel'), + }) + .describe('Forum unreads result'), + }), + z.object({ + type: z.literal('guild_thread_flip_data').describe('Response type for a thread gate re-evaluation'), + data: z + .object({ + config_version: z.number().int().min(0).describe('Channel threads config version the api evaluated'), + thread_gate: ThreadGateRpcResponse.describe('Channel threads gate decision for the guild'), + thread_tainted: z.boolean().describe('Whether the guild has ever held thread data'), + channels: z.array(ChannelResponse).describe('Guild channels, including forums when active'), + roles: z.array(GuildRoleResponse).describe('Guild roles'), + threads: z.array(ChannelResponse).optional().describe('Active threads when active'), + thread_members: z.array(ThreadMemberRpcResponse).optional().describe('Active thread members when active'), + thread_parent_settings: z + .array(ThreadParentSettingsRpcResponse) + .optional() + .describe('Thread settings of parents when active'), + }) + .describe('Thread gate re-evaluation result'), + }), ]); export type RpcResponse = z.infer; diff --git a/packages/schema/src/domains/user/UserRequestSchemas.ts b/packages/schema/src/domains/user/UserRequestSchemas.ts index 197274c96..ba02dc06e 100644 --- a/packages/schema/src/domains/user/UserRequestSchemas.ts +++ b/packages/schema/src/domains/user/UserRequestSchemas.ts @@ -31,6 +31,7 @@ import { createBitflagInt32Type, createNamedStringLiteralUnion, createStringType, + Int32Type, SnowflakeStringType, SnowflakeType, withFieldDescription, @@ -414,6 +415,16 @@ const MuteConfigSchema = z selected_time_window: z.number().int().describe('Selected mute duration'), }) .nullish(); +export const ThreadMemberSettingsRequest = z.object({ + flags: Int32Type.optional().describe( + 'Thread member notification flags (ALL_MESSAGES 1<<1, ONLY_MENTIONS 1<<2, NO_MESSAGES 1<<3)', + ), + muted: z.boolean().optional().describe('Whether the thread is muted'), + mute_config: MuteConfigSchema.describe('Thread mute configuration'), +}); + +export type ThreadMemberSettingsRequest = z.infer; + const ChannelOverrideSchema = z.object({ collapsed: z.boolean().describe('Channel category collapsed'), message_notifications: withFieldDescription(UserNotificationSettingsSchema, 'Channel notification level'), @@ -422,6 +433,9 @@ const ChannelOverrideSchema = z.object({ unread_badges: withFieldDescription(UserNotificationSettingsSchema, 'Unread badges level override for this channel') .nullish() .describe('Unread badges level override for this channel (null = inherit)'), + flags: Int32Type.optional().describe( + 'Channel override flags (NEW_FORUM_THREADS_OFF 1<<13, NEW_FORUM_THREADS_ON 1<<14)', + ), }); export const UserGuildSettingsUpdateRequest = z .object({ diff --git a/packages/schema/src/domains/user/UserResponseSchemas.ts b/packages/schema/src/domains/user/UserResponseSchemas.ts index 099aa8cba..15285db31 100644 --- a/packages/schema/src/domains/user/UserResponseSchemas.ts +++ b/packages/schema/src/domains/user/UserResponseSchemas.ts @@ -396,6 +396,9 @@ const UserGuildChannelOverride = z.object({ unread_badges: withFieldDescription(UserNotificationSettingsSchema, 'Unread badges level override for this channel') .nullish() .describe('Unread badges level override for this channel (null = inherit)'), + flags: Int32Type.optional().describe( + 'Channel override flags (NEW_FORUM_THREADS_OFF 1<<13, NEW_FORUM_THREADS_ON 1<<14)', + ), }); export const UserGuildSettingsResponse = z.object({ guild_id: SnowflakeStringType.nullable().describe('The ID of the guild these settings apply to'), diff --git a/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts b/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts index 4d8dcdf0a..d2241c968 100644 --- a/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts +++ b/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts @@ -7,6 +7,11 @@ import { MessageFlagsDescriptions, } from '@fluxer/constants/src/ChannelConstants'; import {AVATAR_MAX_SIZE, MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants'; +import { + MAX_APPLIED_TAGS_PER_THREAD, + THREAD_NAME_MAX_LENGTH, + THREAD_NAME_MIN_LENGTH, +} from '@fluxer/constants/src/ThreadConstants'; import {ClientUploadedAttachmentRequest} from '@fluxer/schema/src/domains/message/AttachmentSchemas'; import { MessageContentRequest, @@ -27,6 +32,7 @@ import { } from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {URLType} from '@fluxer/schema/src/primitives/UrlValidators'; import {WebhookNameType} from '@fluxer/schema/src/primitives/UserValidators'; +import {withSchemaMetadata} from '@fluxer/schema/src/SchemaMetadata'; import {z} from 'zod'; export const WebhookCreateRequest = z.object({ @@ -108,11 +114,35 @@ const WebhookMessageRequestBase = z }) .partial(); +const CHANNEL_THREADS_EXPERIMENT = {experiment: 'channel_threads'} as const; + +export const WebhookForumPostRequestFields = { + thread_name: withSchemaMetadata( + createStringType(THREAD_NAME_MIN_LENGTH, THREAD_NAME_MAX_LENGTH).describe( + 'Name of the post to create when the webhook belongs to a forum or media channel (1-100 characters)', + ), + CHANNEL_THREADS_EXPERIMENT, + ).optional(), + applied_tags: withSchemaMetadata( + z + .array(SnowflakeType) + .max(MAX_APPLIED_TAGS_PER_THREAD) + .describe('IDs of the tags to apply to the new post (max 5)'), + CHANNEL_THREADS_EXPERIMENT, + ).optional(), +}; + +const WebhookThreadIdQuery = withSchemaMetadata( + z.string().describe('ID of a thread in the webhook channel to target instead of the channel itself'), + CHANNEL_THREADS_EXPERIMENT, +).nullish(); + export const WebhookMessageRequest = WebhookMessageRequestBase.extend({ attachments: z .array(ClientUploadedAttachmentRequest) .optional() .describe('Array of attachments uploaded through the presigned upload endpoint'), + ...WebhookForumPostRequestFields, }); export type WebhookMessageRequest = z.infer; @@ -122,6 +152,7 @@ export const WebhookMultipartMessageRequest = WebhookMessageRequestBase.extend({ .array(WebhookMultipartAttachmentRequest) .optional() .describe('Array of multipart attachment metadata objects'), + ...WebhookForumPostRequestFields, }); export type WebhookMultipartMessageRequest = z.infer; @@ -146,10 +177,17 @@ export type WebhookMessageEditRequest = z.infer; +export const WebhookMessageQueryRequest = z.object({ + thread_id: WebhookThreadIdQuery, +}); + +export type WebhookMessageQueryRequest = z.infer; + const SlackAttachmentFieldSchema = z.object({ title: createUnboundedStringType().optional().describe('Title of the field'), value: createUnboundedStringType().optional().describe('Value of the field'), diff --git a/packages/schema/src/primitives/AuditLogValidators.ts b/packages/schema/src/primitives/AuditLogValidators.ts index 18b059174..974a49ca4 100644 --- a/packages/schema/src/primitives/AuditLogValidators.ts +++ b/packages/schema/src/primitives/AuditLogValidators.ts @@ -3,44 +3,58 @@ import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {createInt32EnumType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +const AUDIT_LOG_ACTION_TYPE_PAIRS = [ + [AuditLogActionType.GUILD_UPDATE, 'GUILD_UPDATE', 'Guild settings were updated'], + [AuditLogActionType.CHANNEL_CREATE, 'CHANNEL_CREATE', 'Channel was created'], + [AuditLogActionType.CHANNEL_UPDATE, 'CHANNEL_UPDATE', 'Channel was updated'], + [AuditLogActionType.CHANNEL_DELETE, 'CHANNEL_DELETE', 'Channel was deleted'], + [AuditLogActionType.CHANNEL_OVERWRITE_CREATE, 'CHANNEL_OVERWRITE_CREATE', 'Permission overwrite was created'], + [AuditLogActionType.CHANNEL_OVERWRITE_UPDATE, 'CHANNEL_OVERWRITE_UPDATE', 'Permission overwrite was updated'], + [AuditLogActionType.CHANNEL_OVERWRITE_DELETE, 'CHANNEL_OVERWRITE_DELETE', 'Permission overwrite was deleted'], + [AuditLogActionType.MEMBER_KICK, 'MEMBER_KICK', 'Member was kicked'], + [AuditLogActionType.MEMBER_PRUNE, 'MEMBER_PRUNE', 'Members were pruned'], + [AuditLogActionType.MEMBER_BAN_ADD, 'MEMBER_BAN_ADD', 'Member was banned'], + [AuditLogActionType.MEMBER_BAN_REMOVE, 'MEMBER_BAN_REMOVE', 'Member ban was removed'], + [AuditLogActionType.MEMBER_UPDATE, 'MEMBER_UPDATE', 'Member was updated'], + [AuditLogActionType.MEMBER_ROLE_UPDATE, 'MEMBER_ROLE_UPDATE', 'Member roles were updated'], + [AuditLogActionType.MEMBER_MOVE, 'MEMBER_MOVE', 'Member was moved to a different voice channel'], + [AuditLogActionType.MEMBER_DISCONNECT, 'MEMBER_DISCONNECT', 'Member was disconnected from a voice channel'], + [AuditLogActionType.BOT_ADD, 'BOT_ADD', 'Bot was added to the guild'], + [AuditLogActionType.ROLE_CREATE, 'ROLE_CREATE', 'Role was created'], + [AuditLogActionType.ROLE_UPDATE, 'ROLE_UPDATE', 'Role was updated'], + [AuditLogActionType.ROLE_DELETE, 'ROLE_DELETE', 'Role was deleted'], + [AuditLogActionType.INVITE_CREATE, 'INVITE_CREATE', 'Invite was created'], + [AuditLogActionType.INVITE_UPDATE, 'INVITE_UPDATE', 'Invite was updated'], + [AuditLogActionType.INVITE_DELETE, 'INVITE_DELETE', 'Invite was deleted'], + [AuditLogActionType.WEBHOOK_CREATE, 'WEBHOOK_CREATE', 'Webhook was created'], + [AuditLogActionType.WEBHOOK_UPDATE, 'WEBHOOK_UPDATE', 'Webhook was updated'], + [AuditLogActionType.WEBHOOK_DELETE, 'WEBHOOK_DELETE', 'Webhook was deleted'], + [AuditLogActionType.EMOJI_CREATE, 'EMOJI_CREATE', 'Emoji was created'], + [AuditLogActionType.EMOJI_UPDATE, 'EMOJI_UPDATE', 'Emoji was updated'], + [AuditLogActionType.EMOJI_DELETE, 'EMOJI_DELETE', 'Emoji was deleted'], + [AuditLogActionType.STICKER_CREATE, 'STICKER_CREATE', 'Sticker was created'], + [AuditLogActionType.STICKER_UPDATE, 'STICKER_UPDATE', 'Sticker was updated'], + [AuditLogActionType.STICKER_DELETE, 'STICKER_DELETE', 'Sticker was deleted'], + [AuditLogActionType.MESSAGE_DELETE, 'MESSAGE_DELETE', 'Message was deleted'], + [AuditLogActionType.MESSAGE_BULK_DELETE, 'MESSAGE_BULK_DELETE', 'Messages were bulk deleted'], + [AuditLogActionType.MESSAGE_PIN, 'MESSAGE_PIN', 'Message was pinned'], + [AuditLogActionType.MESSAGE_UNPIN, 'MESSAGE_UNPIN', 'Message was unpinned'], +] as const; + +const THREAD_AUDIT_LOG_ACTION_TYPE_PAIRS = [ + [AuditLogActionType.THREAD_CREATE, 'THREAD_CREATE', 'Thread was created'], + [AuditLogActionType.THREAD_UPDATE, 'THREAD_UPDATE', 'Thread was updated'], + [AuditLogActionType.THREAD_DELETE, 'THREAD_DELETE', 'Thread was deleted'], +] as const; + export const AuditLogActionTypeSchema = createInt32EnumType( - [ - [AuditLogActionType.GUILD_UPDATE, 'GUILD_UPDATE', 'Guild settings were updated'], - [AuditLogActionType.CHANNEL_CREATE, 'CHANNEL_CREATE', 'Channel was created'], - [AuditLogActionType.CHANNEL_UPDATE, 'CHANNEL_UPDATE', 'Channel was updated'], - [AuditLogActionType.CHANNEL_DELETE, 'CHANNEL_DELETE', 'Channel was deleted'], - [AuditLogActionType.CHANNEL_OVERWRITE_CREATE, 'CHANNEL_OVERWRITE_CREATE', 'Permission overwrite was created'], - [AuditLogActionType.CHANNEL_OVERWRITE_UPDATE, 'CHANNEL_OVERWRITE_UPDATE', 'Permission overwrite was updated'], - [AuditLogActionType.CHANNEL_OVERWRITE_DELETE, 'CHANNEL_OVERWRITE_DELETE', 'Permission overwrite was deleted'], - [AuditLogActionType.MEMBER_KICK, 'MEMBER_KICK', 'Member was kicked'], - [AuditLogActionType.MEMBER_PRUNE, 'MEMBER_PRUNE', 'Members were pruned'], - [AuditLogActionType.MEMBER_BAN_ADD, 'MEMBER_BAN_ADD', 'Member was banned'], - [AuditLogActionType.MEMBER_BAN_REMOVE, 'MEMBER_BAN_REMOVE', 'Member ban was removed'], - [AuditLogActionType.MEMBER_UPDATE, 'MEMBER_UPDATE', 'Member was updated'], - [AuditLogActionType.MEMBER_ROLE_UPDATE, 'MEMBER_ROLE_UPDATE', 'Member roles were updated'], - [AuditLogActionType.MEMBER_MOVE, 'MEMBER_MOVE', 'Member was moved to a different voice channel'], - [AuditLogActionType.MEMBER_DISCONNECT, 'MEMBER_DISCONNECT', 'Member was disconnected from a voice channel'], - [AuditLogActionType.BOT_ADD, 'BOT_ADD', 'Bot was added to the guild'], - [AuditLogActionType.ROLE_CREATE, 'ROLE_CREATE', 'Role was created'], - [AuditLogActionType.ROLE_UPDATE, 'ROLE_UPDATE', 'Role was updated'], - [AuditLogActionType.ROLE_DELETE, 'ROLE_DELETE', 'Role was deleted'], - [AuditLogActionType.INVITE_CREATE, 'INVITE_CREATE', 'Invite was created'], - [AuditLogActionType.INVITE_UPDATE, 'INVITE_UPDATE', 'Invite was updated'], - [AuditLogActionType.INVITE_DELETE, 'INVITE_DELETE', 'Invite was deleted'], - [AuditLogActionType.WEBHOOK_CREATE, 'WEBHOOK_CREATE', 'Webhook was created'], - [AuditLogActionType.WEBHOOK_UPDATE, 'WEBHOOK_UPDATE', 'Webhook was updated'], - [AuditLogActionType.WEBHOOK_DELETE, 'WEBHOOK_DELETE', 'Webhook was deleted'], - [AuditLogActionType.EMOJI_CREATE, 'EMOJI_CREATE', 'Emoji was created'], - [AuditLogActionType.EMOJI_UPDATE, 'EMOJI_UPDATE', 'Emoji was updated'], - [AuditLogActionType.EMOJI_DELETE, 'EMOJI_DELETE', 'Emoji was deleted'], - [AuditLogActionType.STICKER_CREATE, 'STICKER_CREATE', 'Sticker was created'], - [AuditLogActionType.STICKER_UPDATE, 'STICKER_UPDATE', 'Sticker was updated'], - [AuditLogActionType.STICKER_DELETE, 'STICKER_DELETE', 'Sticker was deleted'], - [AuditLogActionType.MESSAGE_DELETE, 'MESSAGE_DELETE', 'Message was deleted'], - [AuditLogActionType.MESSAGE_BULK_DELETE, 'MESSAGE_BULK_DELETE', 'Messages were bulk deleted'], - [AuditLogActionType.MESSAGE_PIN, 'MESSAGE_PIN', 'Message was pinned'], - [AuditLogActionType.MESSAGE_UNPIN, 'MESSAGE_UNPIN', 'Message was unpinned'], - ], + [...AUDIT_LOG_ACTION_TYPE_PAIRS, ...THREAD_AUDIT_LOG_ACTION_TYPE_PAIRS], 'The type of action that occurred', 'AuditLogActionType', ); + +export const AuditLogActionTypeFilterSchema = createInt32EnumType( + [...AUDIT_LOG_ACTION_TYPE_PAIRS], + 'The type of action that occurred', + 'AuditLogActionTypeFilter', +); diff --git a/packages/schema/src/primitives/ChannelValidators.ts b/packages/schema/src/primitives/ChannelValidators.ts index 42ac3742e..7d82a839b 100644 --- a/packages/schema/src/primitives/ChannelValidators.ts +++ b/packages/schema/src/primitives/ChannelValidators.ts @@ -30,6 +30,11 @@ export const ChannelTypeSchema = createInt32EnumType( 'GUILD_ANNOUNCEMENT', 'A guild channel whose messages can be published to channels that follow it', ], + [ChannelTypes.ANNOUNCEMENT_THREAD, 'ANNOUNCEMENT_THREAD', 'A public thread within an announcement channel'], + [ChannelTypes.PUBLIC_THREAD, 'PUBLIC_THREAD', 'A public thread within a text, forum or media channel'], + [ChannelTypes.PRIVATE_THREAD, 'PRIVATE_THREAD', 'A private thread within a text channel'], + [ChannelTypes.GUILD_FORUM, 'GUILD_FORUM', 'A channel that can only contain threads'], + [ChannelTypes.GUILD_MEDIA, 'GUILD_MEDIA', 'A channel that can only contain threads in a gallery view'], [ChannelTypes.GUILD_LINK, 'GUILD_LINK', 'A link channel for external resources'], [ChannelTypes.DM_PERSONAL_NOTES, 'DM_PERSONAL_NOTES', 'Personal notes DM channel'], ], diff --git a/packages/schema/src/primitives/MessageValidators.ts b/packages/schema/src/primitives/MessageValidators.ts index 237e06665..edc542358 100644 --- a/packages/schema/src/primitives/MessageValidators.ts +++ b/packages/schema/src/primitives/MessageValidators.ts @@ -28,7 +28,13 @@ export const MessageTypeSchema = createInt32EnumType( 'CHANNEL_FOLLOW_ADD', 'System message posted when a channel starts following an announcement channel', ], + [MessageTypes.THREAD_CREATED, 'THREAD_CREATED', 'A system message indicating a thread was created'], [MessageTypes.REPLY, 'REPLY', 'A reply message'], + [ + MessageTypes.THREAD_STARTER_MESSAGE, + 'THREAD_STARTER_MESSAGE', + 'The first message of a thread, pointing at its source', + ], ], 'The type of message', 'MessageType', diff --git a/packages/schema/src/primitives/tests/ChannelValidators.test.ts b/packages/schema/src/primitives/tests/ChannelValidators.test.ts index c7197fe2b..8ab890fa4 100644 --- a/packages/schema/src/primitives/tests/ChannelValidators.test.ts +++ b/packages/schema/src/primitives/tests/ChannelValidators.test.ts @@ -1,15 +1,27 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import { AuditLogReasonType, ChannelNameType, + ChannelTypeSchema, GeneralChannelNameType, VanityURLCodeType, } from '@fluxer/schema/src/primitives/ChannelValidators'; import {MAX_STRING_PROCESSING_LENGTH} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {describe, expect, it} from 'vitest'; +describe('ChannelTypeSchema', () => { + it.each(Object.values(ChannelTypes))('accepts channel type %i', (value) => { + expect(ChannelTypeSchema.parse(value)).toBe(value); + }); + + it.each([9, 13, 14, 17, -1, '0', null])('rejects unknown channel type %j', (value) => { + expect(ChannelTypeSchema.safeParse(value).success).toBe(false); + }); +}); + describe('ChannelNameType', () => { it.each([ ['general', 'general'], diff --git a/packages/schema/src/primitives/tests/MessageValidators.test.ts b/packages/schema/src/primitives/tests/MessageValidators.test.ts index 953acca1d..11ba6eb94 100644 --- a/packages/schema/src/primitives/tests/MessageValidators.test.ts +++ b/packages/schema/src/primitives/tests/MessageValidators.test.ts @@ -12,7 +12,14 @@ describe('MessageTypeSchema', () => { }, ); - it.each([MessageTypes.CLIENT_SYSTEM, 8, -1, 0.5, '0', null])('rejects non-wire message type %j', (value) => { + it.each([MessageTypes.THREAD_CREATED, MessageTypes.THREAD_STARTER_MESSAGE])( + 'accepts thread message type %i', + (value) => { + expect(MessageTypeSchema.parse(value)).toBe(value); + }, + ); + + it.each([MessageTypes.CLIENT_SYSTEM, 8, 20, -1, 0.5, '0', null])('rejects non-wire message type %j', (value) => { expect(MessageTypeSchema.safeParse(value).success).toBe(false); }); }); diff --git a/tools/dev/cassandra_target_schema.json b/tools/dev/cassandra_target_schema.json index 627e4da9f..ea939fad8 100644 --- a/tools/dev/cassandra_target_schema.json +++ b/tools/dev/cassandra_target_schema.json @@ -22,6 +22,10 @@ { "name": "unread_badges", "type": "int" + }, + { + "name": "flags", + "type": "int" } ] }, @@ -50,6 +54,31 @@ } ] }, + { + "name": "forum_tag", + "fields": [ + { + "name": "id", + "type": "bigint" + }, + { + "name": "name", + "type": "text" + }, + { + "name": "moderated", + "type": "boolean" + }, + { + "name": "emoji_id", + "type": "bigint" + }, + { + "name": "emoji_name", + "type": "text" + } + ] + }, { "name": "guild_folder", "fields": [ @@ -616,6 +645,29 @@ } ], "tables": [ + { + "name": "active_threads_by_guild", + "columns": [ + { + "name": "guild_id", + "type": "bigint" + }, + { + "name": "thread_id", + "type": "bigint" + }, + { + "name": "parent_id", + "type": "bigint" + }, + { + "name": "type", + "type": "int" + } + ], + "primary_key": "((guild_id), thread_id)", + "options": "" + }, { "name": "admin_api_keys", "columns": [ @@ -1020,6 +1072,33 @@ "primary_key": "((owner_user_id), application_id)", "options": "CLUSTERING ORDER BY (application_id DESC)" }, + { + "name": "archived_threads_by_parent", + "columns": [ + { + "name": "parent_id", + "type": "bigint" + }, + { + "name": "is_private", + "type": "boolean" + }, + { + "name": "archive_timestamp", + "type": "timestamp" + }, + { + "name": "thread_id", + "type": "bigint" + }, + { + "name": "guild_id", + "type": "bigint" + } + ], + "primary_key": "((parent_id, is_private), archive_timestamp, thread_id)", + "options": "CLUSTERING ORDER BY (archive_timestamp DESC, thread_id DESC)" + }, { "name": "attachment_decay_by_expiry", "columns": [ @@ -4866,6 +4945,21 @@ "primary_key": "((subject_user_key, day), created_at, request_id, exposure_index)", "options": "CLUSTERING ORDER BY (created_at DESC, request_id ASC, exposure_index ASC) AND default_time_to_live = 7776000 AND gc_grace_seconds = 864000 AND compaction = {'class': 'TimeWindowCompactionStrategy', 'compaction_window_unit': 'DAYS', 'compaction_window_size': '3'}" }, + { + "name": "forum_pinned_thread", + "columns": [ + { + "name": "parent_id", + "type": "bigint" + }, + { + "name": "thread_id", + "type": "bigint" + } + ], + "primary_key": "((parent_id))", + "options": "" + }, { "name": "gift_codes", "columns": [ @@ -5605,6 +5699,29 @@ "primary_key": "((sticker_id))", "options": "" }, + { + "name": "guild_thread_state", + "columns": [ + { + "name": "guild_id", + "type": "bigint" + }, + { + "name": "first_active_at", + "type": "timestamp" + }, + { + "name": "perms_seeded_at", + "type": "timestamp" + }, + { + "name": "search_backfilled_at", + "type": "timestamp" + } + ], + "primary_key": "((guild_id))", + "options": "" + }, { "name": "guilds", "columns": [ @@ -7046,6 +7163,10 @@ { "name": "provider_environment", "type": "text" + }, + { + "name": "thread_channels", + "type": "boolean" } ], "primary_key": "((user_id), subscription_id)", @@ -7073,6 +7194,14 @@ { "name": "last_pin_timestamp", "type": "timestamp" + }, + { + "name": "flags", + "type": "int" + }, + { + "name": "guild_id", + "type": "bigint" } ], "primary_key": "((user_id), channel_id)", @@ -7449,6 +7578,255 @@ "primary_key": "((user_id), store_key)", "options": "" }, + { + "name": "thread_members", + "columns": [ + { + "name": "thread_id", + "type": "bigint" + }, + { + "name": "user_id", + "type": "bigint" + }, + { + "name": "guild_id", + "type": "bigint" + }, + { + "name": "parent_id", + "type": "bigint" + }, + { + "name": "join_timestamp", + "type": "timestamp" + }, + { + "name": "flags", + "type": "int" + }, + { + "name": "muted", + "type": "boolean" + }, + { + "name": "mute_config", + "type": "frozen" + } + ], + "primary_key": "((thread_id), user_id)", + "options": "" + }, + { + "name": "thread_members_by_user", + "columns": [ + { + "name": "user_id", + "type": "bigint" + }, + { + "name": "guild_id", + "type": "bigint" + }, + { + "name": "parent_id", + "type": "bigint" + }, + { + "name": "is_private", + "type": "boolean" + }, + { + "name": "thread_id", + "type": "bigint" + } + ], + "primary_key": "((user_id), guild_id, parent_id, is_private, thread_id)", + "options": "CLUSTERING ORDER BY (guild_id ASC, parent_id ASC, is_private ASC, thread_id DESC)" + }, + { + "name": "thread_only_channels_by_guild", + "columns": [ + { + "name": "guild_id", + "type": "bigint" + }, + { + "name": "channel_id", + "type": "bigint" + } + ], + "primary_key": "((guild_id), channel_id)", + "options": "" + }, + { + "name": "thread_parent_config", + "columns": [ + { + "name": "guild_id", + "type": "bigint" + }, + { + "name": "channel_id", + "type": "bigint" + }, + { + "name": "flags", + "type": "int" + }, + { + "name": "default_auto_archive_duration", + "type": "int" + }, + { + "name": "default_thread_rate_limit_per_user", + "type": "int" + }, + { + "name": "available_tags", + "type": "frozen>" + }, + { + "name": "default_reaction_emoji_id", + "type": "bigint" + }, + { + "name": "default_reaction_emoji_name", + "type": "text" + }, + { + "name": "default_sort_order", + "type": "int" + }, + { + "name": "default_forum_layout", + "type": "int" + }, + { + "name": "default_tag_setting", + "type": "text" + }, + { + "name": "has_threads", + "type": "boolean" + } + ], + "primary_key": "((guild_id), channel_id)", + "options": "" + }, + { + "name": "thread_state", + "columns": [ + { + "name": "thread_id", + "type": "bigint" + }, + { + "name": "guild_id", + "type": "bigint" + }, + { + "name": "parent_id", + "type": "bigint" + }, + { + "name": "type", + "type": "int" + }, + { + "name": "archived", + "type": "boolean" + }, + { + "name": "locked", + "type": "boolean" + }, + { + "name": "invitable", + "type": "boolean" + }, + { + "name": "auto_archive_duration", + "type": "int" + }, + { + "name": "archive_timestamp", + "type": "timestamp" + }, + { + "name": "created_at", + "type": "timestamp" + }, + { + "name": "flags", + "type": "int" + }, + { + "name": "applied_tags", + "type": "frozen>" + }, + { + "name": "member_count", + "type": "int" + }, + { + "name": "member_ids_preview", + "type": "frozen>" + }, + { + "name": "has_starter", + "type": "boolean" + }, + { + "name": "state_version", + "type": "int" + } + ], + "primary_key": "((thread_id))", + "options": "" + }, + { + "name": "thread_stats", + "columns": [ + { + "name": "thread_id", + "type": "bigint" + }, + { + "name": "message_count", + "type": "int" + }, + { + "name": "total_message_sent", + "type": "int" + } + ], + "primary_key": "((thread_id))", + "options": "" + }, + { + "name": "threads_by_parent", + "columns": [ + { + "name": "parent_id", + "type": "bigint" + }, + { + "name": "thread_id", + "type": "bigint" + }, + { + "name": "guild_id", + "type": "bigint" + }, + { + "name": "type", + "type": "int" + } + ], + "primary_key": "((parent_id), thread_id)", + "options": "" + }, { "name": "user_connections", "columns": [