Bridge.Database.AfterMigration("sky_phone", function() local reaction_kinds = { like = true, bookmark = true } local report_reasons = { spam = true, harassment = true, dangerous = true, illegal = true, other = true } local function trim(value) if type(value) ~= "string" then return nil end return value:match("^%s*(.-)%s*$") end local function valid_text(value, minimum, maximum) local length = type(value) == "string" and utf8.len(value) or nil return length and length >= minimum and length <= maximum end local function new_id() local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) if not rows[1] or type(rows[1].id) ~= "string" then error("[sky_phone] Database did not generate a Feather id.") end return rows[1].id end local function normalize_handle(value) local handle = trim(value) if not handle then return nil end handle = handle:lower():gsub("^@", "") if #handle < 3 or #handle > Config.Feather.HandleMaxLength or not handle:match("^[a-z0-9][a-z0-9_]*[a-z0-9]$") then return nil end return handle end local function require_profile(source) local account, error_response = SkyPhone.RequireAccount(source) if not account then return nil, error_response end local rows = Bridge.Database.Query( "SELECT * FROM `sky_phone_feather_profiles` WHERE `account_id` = ? LIMIT 1", { account.id } ) if not rows[1] then return nil, { success = false, error = "feather_profile_required" } end return rows[1], nil end local function are_blocked(first_id, second_id) return Bridge.Database.Query([[SELECT `id` FROM `sky_phone_feather_blocks` WHERE (`blocker_id` = ? AND `blocked_id` = ?) OR (`blocker_id` = ? AND `blocked_id` = ?) LIMIT 1]], { first_id, second_id, second_id, first_id, })[1] ~= nil end local function hydrate_profile(profile, viewer_id) profile.id = tonumber(profile.id) profile.verified = tonumber(profile.verified) == 1 profile.is_owner = profile.id == viewer_id profile.is_following = tonumber(profile.is_following) == 1 profile.followers = tonumber(profile.followers) or 0 profile.following = tonumber(profile.following) or 0 profile.post_count = tonumber(profile.post_count) or 0 return profile end local function load_profile(profile_id, viewer_id) local rows = Bridge.Database.Query([[ SELECT p.`id`, p.`handle`, p.`display_name`, p.`bio`, p.`verified`, m.`url` AS `avatar_url`, EXISTS(SELECT 1 FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = p.`id`) AS `is_following`, (SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`following_id` = p.`id`) AS `followers`, (SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = p.`id`) AS `following`, (SELECT COUNT(*) FROM `sky_phone_feather_posts` fp WHERE fp.`profile_id` = p.`id` AND fp.`status` = 'published') AS `post_count` FROM `sky_phone_feather_profiles` p LEFT JOIN `sky_phone_media` m ON m.`id` = p.`avatar_media_id` WHERE p.`id` = ? LIMIT 1 ]], { viewer_id, profile_id }) return rows[1] and hydrate_profile(rows[1], viewer_id) or nil end local function hydrate_posts(rows, viewer_id) local ids = {} for _, post in ipairs(rows) do post.profile_id = tonumber(post.profile_id) post.verified = tonumber(post.verified) == 1 post.is_owner = post.profile_id == viewer_id post.is_following = tonumber(post.is_following) == 1 post.is_liked = tonumber(post.is_liked) == 1 post.is_bookmarked = tonumber(post.is_bookmarked) == 1 post.like_count = tonumber(post.like_count) or 0 post.reply_count = tonumber(post.reply_count) or 0 post.created_at = (tonumber(post.created_at_unix) or 0) * 1000 post.created_at_unix = nil post.media = {} ids[#ids + 1] = post.id end if #ids == 0 then return rows end local placeholders = {} for index = 1, #ids do placeholders[index] = "?" end local media_rows = Bridge.Database.Query(([[ SELECT pm.`post_id`, pm.`media_id` AS `id`, m.`url`, m.`media_type` FROM `sky_phone_feather_post_media` pm JOIN `sky_phone_media` m ON m.`id` = pm.`media_id` WHERE pm.`post_id` IN (%s) ORDER BY pm.`post_id`, pm.`sort_order` ]]):format(table.concat(placeholders, ",")), ids) local posts = {} for _, post in ipairs(rows) do posts[post.id] = post end for _, media in ipairs(media_rows) do media.id = tonumber(media.id) posts[media.post_id].media[#posts[media.post_id].media + 1] = media end return rows end local function list_posts(viewer_id, condition, values, ranking, offset) local parameters = { viewer_id, viewer_id, viewer_id, viewer_id } for _, value in ipairs(values) do parameters[#parameters + 1] = value end parameters[#parameters + 1] = viewer_id parameters[#parameters + 1] = viewer_id parameters[#parameters + 1] = Config.Feather.PageSize + 1 parameters[#parameters + 1] = offset local rows = Bridge.Database.Query(([[ SELECT fp.`id`, fp.`profile_id`, fp.`body`, fp.`reply_to_id`, fp.`quote_id`, UNIX_TIMESTAMP(fp.`created_at`) AS `created_at_unix`, p.`handle`, p.`display_name`, p.`verified`, avatar.`url` AS `avatar_url`, (fp.`profile_id` = ?) AS `is_owner`, EXISTS(SELECT 1 FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = fp.`profile_id`) AS `is_following`, EXISTS(SELECT 1 FROM `sky_phone_feather_reactions` r WHERE r.`profile_id` = ? AND r.`post_id` = fp.`id` AND r.`kind` = 'like') AS `is_liked`, EXISTS(SELECT 1 FROM `sky_phone_feather_reactions` r WHERE r.`profile_id` = ? AND r.`post_id` = fp.`id` AND r.`kind` = 'bookmark') AS `is_bookmarked`, (SELECT COUNT(*) FROM `sky_phone_feather_reactions` r WHERE r.`post_id` = fp.`id` AND r.`kind` = 'like') AS `like_count`, (SELECT COUNT(*) FROM `sky_phone_feather_posts` reply WHERE reply.`reply_to_id` = fp.`id` AND reply.`status` = 'published') AS `reply_count` FROM `sky_phone_feather_posts` fp JOIN `sky_phone_feather_profiles` p ON p.`id` = fp.`profile_id` LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` WHERE fp.`status` = 'published' AND %s AND NOT EXISTS(SELECT 1 FROM `sky_phone_feather_blocks` b WHERE (b.`blocker_id` = ? AND b.`blocked_id` = fp.`profile_id`) OR (b.`blocked_id` = ? AND b.`blocker_id` = fp.`profile_id`)) ORDER BY %s LIMIT ? OFFSET ? ]]):format(condition, ranking), parameters) local has_more = #rows > Config.Feather.PageSize if has_more then rows[#rows] = nil end return { items = hydrate_posts(rows, viewer_id), offset = offset, hasMore = has_more } end local function notify_profile(recipient_id, actor, kind, post_id) if recipient_id == tonumber(actor.id) then return end local recipients = Bridge.Database.Query( "SELECT `account_id` FROM `sky_phone_feather_profiles` WHERE `id` = ? LIMIT 1", { recipient_id } ) if not recipients[1] then return end Bridge.Database.Query([[INSERT INTO `sky_phone_feather_notifications` (`id`, `recipient_id`, `actor_id`, `post_id`, `kind`) VALUES (?, ?, ?, ?, ?)]], { new_id(), recipient_id, actor.id, post_id, kind, }) SkyPhone.NotifyAccountDevices(tonumber(recipients[1].account_id), "sky_phone:feather:new", { actor = actor.display_name, kind = kind, postId = post_id, }) end local function suggestions(profile_id) local rows = Bridge.Database.Query([[ SELECT p.`id`, p.`handle`, p.`display_name`, p.`bio`, p.`verified`, m.`url` AS `avatar_url`, 0 AS `is_following`, (SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`following_id` = p.`id`) AS `followers`, (SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = p.`id`) AS `following`, (SELECT COUNT(*) FROM `sky_phone_feather_posts` fp WHERE fp.`profile_id` = p.`id` AND fp.`status` = 'published') AS `post_count` FROM `sky_phone_feather_profiles` p LEFT JOIN `sky_phone_media` m ON m.`id` = p.`avatar_media_id` WHERE p.`id` <> ? AND NOT EXISTS(SELECT 1 FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = p.`id`) AND NOT EXISTS(SELECT 1 FROM `sky_phone_feather_blocks` b WHERE (b.`blocker_id` = ? AND b.`blocked_id` = p.`id`) OR (b.`blocked_id` = ? AND b.`blocker_id` = p.`id`)) ORDER BY `followers` DESC, p.`created_at` DESC LIMIT 5 ]], { profile_id, profile_id, profile_id, profile_id }) for _, row in ipairs(rows) do hydrate_profile(row, profile_id) end return rows end local function search_profiles(profile_id, search) local pattern = "%" .. search:gsub("[%%_]", "\\%0") .. "%" local prefix = search:gsub("[%%_]", "\\%0") .. "%" local rows = Bridge.Database.Query([[ SELECT p.`id`, p.`handle`, p.`display_name`, p.`bio`, p.`verified`, m.`url` AS `avatar_url`, EXISTS(SELECT 1 FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = p.`id`) AS `is_following`, (SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`following_id` = p.`id`) AS `followers`, (SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = p.`id`) AS `following`, (SELECT COUNT(*) FROM `sky_phone_feather_posts` fp WHERE fp.`profile_id` = p.`id` AND fp.`status` = 'published') AS `post_count` FROM `sky_phone_feather_profiles` p LEFT JOIN `sky_phone_media` m ON m.`id` = p.`avatar_media_id` WHERE p.`id` <> ? AND (p.`handle` LIKE ? OR p.`display_name` LIKE ?) AND NOT EXISTS(SELECT 1 FROM `sky_phone_feather_blocks` b WHERE (b.`blocker_id` = ? AND b.`blocked_id` = p.`id`) OR (b.`blocked_id` = ? AND b.`blocker_id` = p.`id`)) ORDER BY (p.`handle` = ?) DESC, (p.`handle` LIKE ?) DESC, `followers` DESC, p.`created_at` DESC LIMIT ? ]], { profile_id, profile_id, pattern, pattern, profile_id, profile_id, search, prefix, Config.Feather.PageSize, }) for _, row in ipairs(rows) do hydrate_profile(row, profile_id) end return rows end local function list_connections(profile_id, viewer_id, mode) local join_condition = mode == "followers" and "connection.`follower_id` = p.`id`" or "connection.`following_id` = p.`id`" local owner_condition = mode == "followers" and "connection.`following_id` = ?" or "connection.`follower_id` = ?" local rows = Bridge.Database.Query(([[ SELECT p.`id`, p.`handle`, p.`display_name`, p.`bio`, p.`verified`, avatar.`url` AS `avatar_url`, EXISTS(SELECT 1 FROM `sky_phone_feather_follows` viewer_follow WHERE viewer_follow.`follower_id` = ? AND viewer_follow.`following_id` = p.`id`) AS `is_following`, (SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`following_id` = p.`id`) AS `followers`, (SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = p.`id`) AS `following`, (SELECT COUNT(*) FROM `sky_phone_feather_posts` fp WHERE fp.`profile_id` = p.`id` AND fp.`status` = 'published') AS `post_count` FROM `sky_phone_feather_follows` connection JOIN `sky_phone_feather_profiles` p ON %s LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` WHERE %s AND NOT EXISTS(SELECT 1 FROM `sky_phone_feather_blocks` b WHERE (b.`blocker_id` = ? AND b.`blocked_id` = p.`id`) OR (b.`blocked_id` = ? AND b.`blocker_id` = p.`id`)) ORDER BY p.`display_name`, p.`handle` ]]):format(join_condition, owner_condition), { viewer_id, profile_id, viewer_id, viewer_id }) for _, row in ipairs(rows) do hydrate_profile(row, viewer_id) end return rows end local function extract_hashtags(body) local hashtags = {} local seen = {} for tag in body:gmatch("#([%w_]+)") do local normalized = tag:lower() if #normalized <= Config.Feather.HashtagMaxLength and not seen[normalized] then seen[normalized] = true hashtags[#hashtags + 1] = normalized end end return hashtags end local function trending_hashtags() local rows = Bridge.Database.Query([[ SELECT h.`tag`, COUNT(*) AS `post_count` FROM `sky_phone_feather_hashtags` h JOIN `sky_phone_feather_posts` fp ON fp.`id` = h.`post_id` AND fp.`status` = 'published' GROUP BY h.`tag` ORDER BY `post_count` DESC, h.`tag` ASC LIMIT ? ]], { Config.Feather.TrendingTopicLimit }) local topics = {} for _, row in ipairs(rows) do topics[#topics + 1] = { tag = "#" .. row.tag, count = tonumber(row.post_count) or 0 } end return topics end local function backfill_hashtags() local posts = Bridge.Database.Query([[ SELECT fp.`id`, fp.`body` FROM `sky_phone_feather_posts` fp WHERE fp.`status` = 'published' AND fp.`body` LIKE '%#%' ]], {}) local statements = {} for _, post in ipairs(posts) do for _, tag in ipairs(extract_hashtags(post.body)) do statements[#statements + 1] = { query = "INSERT IGNORE INTO `sky_phone_feather_hashtags` (`post_id`, `tag`) VALUES (?, ?)", params = { post.id, tag }, } if #statements == 500 then if not Bridge.Database.Transaction(statements) then error("[sky_phone] Failed to backfill Feather hashtags.") end statements = {} end end end if #statements > 0 and not Bridge.Database.Transaction(statements) then error("[sky_phone] Failed to backfill Feather hashtags.") end end backfill_hashtags() Bridge.Callbacks.Register("sky_phone:feather:bootstrap", function(source) local account, error_response = SkyPhone.RequireAccount(source) if not account then return error_response end local rows = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `account_id` = ? LIMIT 1", { account.id }) if not rows[1] then return { success = true, data = { onboarded = false, topics = trending_hashtags() } } end local profile_id = tonumber(rows[1].id) return { success = true, data = { onboarded = true, profile = load_profile(profile_id, profile_id), feed = list_posts(profile_id, "fp.`reply_to_id` IS NULL", {}, "fp.`created_at` DESC", 0), suggestions = suggestions(profile_id), topics = trending_hashtags(), } } end) Bridge.Callbacks.Register("sky_phone:feather:create-profile", function(source, data) if not SkyPhone.AllowOperation(source, "feather:create-profile", 5, 60) then return { success = false, error = "rate_limited" } end local account, error_response = SkyPhone.RequireAccount(source) if not account then return error_response end data = type(data) == "table" and data or {} local handle = normalize_handle(data.handle) local display_name = trim(data.displayName) local bio = trim(data.bio) or "" if not handle then return { success = false, error = "invalid_handle" } end if not valid_text(display_name, 1, Config.Feather.DisplayNameMaxLength) then return { success = false, error = "invalid_display_name" } end if not valid_text(bio, 0, Config.Feather.BioMaxLength) then return { success = false, error = "invalid_bio" } end local avatar_id = nil if data.avatarId then local media, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, data.avatarId, "photo") if not media then return { success = false, error = media_error } end avatar_id = math.floor(tonumber(data.avatarId)) end local existing = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `account_id` = ? LIMIT 1", { account.id }) if existing[1] then return { success = false, error = "already_registered" } end local duplicate = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `handle` = ? LIMIT 1", { handle }) if duplicate[1] then return { success = false, error = "handle_taken" } end Bridge.Database.Query([[INSERT INTO `sky_phone_feather_profiles` (`account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`) VALUES (?, ?, ?, ?, ?)]], { account.id, handle, display_name, bio, avatar_id, }) return { success = true } end) Bridge.Callbacks.Register("sky_phone:feather:update-profile", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local display_name = trim(data.displayName) local bio = trim(data.bio) or "" if not valid_text(display_name, 1, Config.Feather.DisplayNameMaxLength) then return { success = false, error = "invalid_display_name" } end if not valid_text(bio, 0, Config.Feather.BioMaxLength) then return { success = false, error = "invalid_bio" } end local avatar_id = profile.avatar_media_id and tonumber(profile.avatar_media_id) or nil if data.avatarId then local media, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, data.avatarId, "photo") if not media then return { success = false, error = media_error } end avatar_id = math.floor(tonumber(data.avatarId)) end Bridge.Database.Query("UPDATE `sky_phone_feather_profiles` SET `display_name` = ?, `bio` = ?, `avatar_media_id` = ? WHERE `id` = ?", { display_name, bio, avatar_id, profile.id, }) return { success = true } end) Bridge.Callbacks.Register("sky_phone:feather:feed", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local offset = math.max(0, math.floor(tonumber(data.offset) or 0)) local condition = "fp.`reply_to_id` IS NULL" local values = {} local ranking = "(SELECT COUNT(*) FROM `sky_phone_feather_reactions` rank_reactions WHERE rank_reactions.`post_id` = fp.`id`) DESC, fp.`created_at` DESC" if data.mode == "following" then condition = "fp.`reply_to_id` IS NULL AND (fp.`profile_id` = ? OR EXISTS(SELECT 1 FROM `sky_phone_feather_follows` follow_filter WHERE follow_filter.`follower_id` = ? AND follow_filter.`following_id` = fp.`profile_id`))" values = { profile.id, profile.id } ranking = "fp.`created_at` DESC" end return { success = true, data = list_posts(tonumber(profile.id), condition, values, ranking, offset) } end) Bridge.Callbacks.Register("sky_phone:feather:explore", function(source, data) if not SkyPhone.AllowOperation(source, "feather:explore", Config.Feather.ActionsPerMinute, 60) then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local search = trim(data.search) or "" if not valid_text(search, 0, Config.Feather.SearchMaxLength) then return { success = false, error = "invalid_search" } end local condition = "fp.`reply_to_id` IS NULL" local values = {} if search ~= "" then condition = "fp.`body` LIKE ?" local pattern = "%" .. search:gsub("[%%_]", "\\%0") .. "%" values = { pattern } end return { success = true, data = { posts = list_posts(tonumber(profile.id), condition, values, "fp.`created_at` DESC", 0).items, profiles = suggestions(tonumber(profile.id)), topics = search == "" and trending_hashtags() or nil, } } end) Bridge.Callbacks.Register("sky_phone:feather:network", function(source, data) if not SkyPhone.AllowOperation(source, "feather:network", Config.Feather.ActionsPerMinute, 60) then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local search = trim(data.search) or "" if not valid_text(search, 0, Config.Feather.SearchMaxLength) then return { success = false, error = "invalid_search" } end search = search:gsub("^@", "") local profile_id = tonumber(profile.id) return { success = true, data = { results = search ~= "" and search_profiles(profile_id, search) or {}, suggestions = suggestions(profile_id), } } end) Bridge.Callbacks.Register("sky_phone:feather:create-post", function(source, data) if not SkyPhone.AllowOperation(source, "feather:create-post", Config.Feather.PostsPerMinute, 60) then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local body = trim(data.body) or "" local media_ids = type(data.mediaIds) == "table" and data.mediaIds or {} if not valid_text(body, #media_ids > 0 and 0 or 1, Config.Feather.TextMaxLength) then return { success = false, error = "invalid_post" } end if #media_ids > Config.Feather.MaxImages then return { success = false, error = "too_many_images" } end local reply_to_id = type(data.replyToId) == "string" and data.replyToId or nil local quote_id = type(data.quoteId) == "string" and data.quoteId or nil local target_id = reply_to_id or quote_id local target = nil if target_id then local targets = Bridge.Database.Query([[SELECT fp.`id`, fp.`profile_id` FROM `sky_phone_feather_posts` fp WHERE fp.`id` = ? AND fp.`status` = 'published' LIMIT 1]], { target_id }) target = targets[1] if not target then return { success = false, error = "post_not_found" } end if are_blocked(tonumber(profile.id), tonumber(target.profile_id)) then return { success = false, error = "profile_blocked" } end end local normalized_media = {} local seen = {} for _, value in ipairs(media_ids) do local id = tonumber(value) if not id or seen[id] then return { success = false, error = "invalid_attachment" } end local media, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, id, "photo") if not media then return { success = false, error = media_error } end seen[id] = true normalized_media[#normalized_media + 1] = math.floor(id) end local post_id = new_id() local statements = {{ query = "INSERT INTO `sky_phone_feather_posts` (`id`, `profile_id`, `body`, `reply_to_id`, `quote_id`) VALUES (?, ?, ?, ?, ?)", params = { post_id, profile.id, body, reply_to_id, quote_id }, }} for index, media_id in ipairs(normalized_media) do statements[#statements + 1] = { query = "INSERT INTO `sky_phone_feather_post_media` (`post_id`, `media_id`, `sort_order`) VALUES (?, ?, ?)", params = { post_id, media_id, index - 1 }, } end for _, tag in ipairs(extract_hashtags(body)) do statements[#statements + 1] = { query = "INSERT INTO `sky_phone_feather_hashtags` (`post_id`, `tag`) VALUES (?, ?)", params = { post_id, tag }, } end if not Bridge.Database.Transaction(statements) then return { success = false, error = "request_failed" } end if target then notify_profile(tonumber(target.profile_id), profile, reply_to_id and "reply" or "quote", post_id) end return { success = true, data = { id = post_id } } end) Bridge.Callbacks.Register("sky_phone:feather:react", function(source, data) if not SkyPhone.AllowOperation(source, "feather:react", Config.Feather.ActionsPerMinute, 60) then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local kind = data.kind if not reaction_kinds[kind] or type(data.id) ~= "string" or type(data.active) ~= "boolean" then return { success = false, error = "invalid_request" } end local posts = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_feather_posts` WHERE `id` = ? AND `status` = 'published' LIMIT 1", { data.id }) if not posts[1] then return { success = false, error = "post_not_found" } end if are_blocked(tonumber(profile.id), tonumber(posts[1].profile_id)) then return { success = false, error = "profile_blocked" } end if data.active then Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_feather_reactions` (`post_id`, `profile_id`, `kind`) VALUES (?, ?, ?)", { data.id, profile.id, kind }) if kind ~= "bookmark" then notify_profile(tonumber(posts[1].profile_id), profile, kind, data.id) end else Bridge.Database.Query("DELETE FROM `sky_phone_feather_reactions` WHERE `post_id` = ? AND `profile_id` = ? AND `kind` = ?", { data.id, profile.id, kind }) end return { success = true } end) Bridge.Callbacks.Register("sky_phone:feather:follow", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local target_id = math.floor(tonumber(data.profileId) or 0) if target_id < 1 or target_id == tonumber(profile.id) or type(data.active) ~= "boolean" then return { success = false, error = "invalid_request" } end local targets = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `id` = ? LIMIT 1", { target_id }) if not targets[1] then return { success = false, error = "profile_not_found" } end if are_blocked(tonumber(profile.id), target_id) then return { success = false, error = "profile_blocked" } end if data.active then Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_feather_follows` (`follower_id`, `following_id`) VALUES (?, ?)", { profile.id, target_id }) notify_profile(target_id, profile, "follow", nil) else Bridge.Database.Query("DELETE FROM `sky_phone_feather_follows` WHERE `follower_id` = ? AND `following_id` = ?", { profile.id, target_id }) end return { success = true } end) Bridge.Callbacks.Register("sky_phone:feather:connections", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local target_id = math.floor(tonumber(data.profileId) or tonumber(profile.id)) local mode = data.mode if target_id < 1 or (mode ~= "followers" and mode ~= "following") then return { success = false, error = "invalid_request" } end local target = load_profile(target_id, tonumber(profile.id)) if not target then return { success = false, error = "profile_not_found" } end if are_blocked(tonumber(profile.id), target_id) then return { success = false, error = "profile_blocked" } end return { success = true, data = { items = list_connections(target_id, tonumber(profile.id), mode), } } end) Bridge.Callbacks.Register("sky_phone:feather:remove-connection", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local target_id = math.floor(tonumber(data.profileId) or 0) local mode = data.mode if target_id < 1 or target_id == tonumber(profile.id) or (mode ~= "followers" and mode ~= "following") then return { success = false, error = "invalid_request" } end if mode == "followers" then Bridge.Database.Query("DELETE FROM `sky_phone_feather_follows` WHERE `follower_id` = ? AND `following_id` = ?", { target_id, profile.id }) else Bridge.Database.Query("DELETE FROM `sky_phone_feather_follows` WHERE `follower_id` = ? AND `following_id` = ?", { profile.id, target_id }) end return { success = true } end) Bridge.Callbacks.Register("sky_phone:feather:profile", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local target_id = tonumber(data.profileId) or tonumber(profile.id) if data.handle then local handle = normalize_handle(data.handle) local rows = handle and Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `handle` = ? LIMIT 1", { handle }) or {} target_id = rows[1] and tonumber(rows[1].id) or 0 end local target = load_profile(target_id, tonumber(profile.id)) if not target then return { success = false, error = "profile_not_found" } end if are_blocked(tonumber(profile.id), target_id) then return { success = false, error = "profile_blocked" } end return { success = true, data = { profile = target, posts = list_posts(tonumber(profile.id), "fp.`profile_id` = ?", { target_id }, "fp.`created_at` DESC", 0).items, } } end) Bridge.Callbacks.Register("sky_phone:feather:bookmarks", function(source) local profile, error_response = require_profile(source) if not profile then return error_response end local bookmarked = list_posts( tonumber(profile.id), "EXISTS(SELECT 1 FROM `sky_phone_feather_reactions` saved WHERE saved.`profile_id` = ? AND saved.`post_id` = fp.`id` AND saved.`kind` = 'bookmark')", { profile.id }, "fp.`created_at` DESC", 0 ) return { success = true, data = bookmarked } end) Bridge.Callbacks.Register("sky_phone:feather:thread", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end local parent = list_posts(tonumber(profile.id), "fp.`id` = ?", { data.id }, "fp.`created_at` DESC", 0).items if not parent[1] then return { success = false, error = "post_not_found" } end local replies = list_posts(tonumber(profile.id), "fp.`reply_to_id` = ?", { data.id }, "fp.`created_at` ASC", 0).items return { success = true, data = { post = parent[1], replies = replies } } end) Bridge.Callbacks.Register("sky_phone:feather:activities", function(source) local profile, error_response = require_profile(source) if not profile then return error_response end local rows = Bridge.Database.Query([[ SELECT n.`id`, n.`kind`, n.`post_id`, UNIX_TIMESTAMP(n.`created_at`) AS `created_at_unix`, n.`read_at`, actor.`id` AS `profile_id`, actor.`handle`, actor.`display_name`, actor.`verified`, avatar.`url` AS `avatar_url` FROM `sky_phone_feather_notifications` n JOIN `sky_phone_feather_profiles` actor ON actor.`id` = n.`actor_id` LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = actor.`avatar_media_id` WHERE n.`recipient_id` = ? ORDER BY n.`created_at` DESC LIMIT 100 ]], { profile.id }) for _, row in ipairs(rows) do row.profile_id = tonumber(row.profile_id) row.verified = tonumber(row.verified) == 1 row.read = row.read_at ~= nil row.read_at = nil row.created_at = (tonumber(row.created_at_unix) or 0) * 1000 row.created_at_unix = nil end return { success = true, data = rows } end) Bridge.Callbacks.Register("sky_phone:feather:mark-activities", function(source) local profile, error_response = require_profile(source) if not profile then return error_response end Bridge.Database.Query("UPDATE `sky_phone_feather_notifications` SET `read_at` = COALESCE(`read_at`, NOW()) WHERE `recipient_id` = ?", { profile.id }) return { success = true } end) Bridge.Callbacks.Register("sky_phone:feather:delete", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end Bridge.Database.Query("UPDATE `sky_phone_feather_posts` SET `status` = 'removed' WHERE `id` = ? AND `profile_id` = ?", { data.id, profile.id }) return { success = true } end) Bridge.Callbacks.Register("sky_phone:feather:block", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end local target_id = type(data) == "table" and math.floor(tonumber(data.profileId) or 0) or 0 if target_id < 1 or target_id == tonumber(profile.id) then return { success = false, error = "invalid_request" } end local targets = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `id` = ? LIMIT 1", { target_id }) if not targets[1] then return { success = false, error = "profile_not_found" } end if not Bridge.Database.Transaction({ { query = "INSERT IGNORE INTO `sky_phone_feather_blocks` (`blocker_id`, `blocked_id`) VALUES (?, ?)", params = { profile.id, target_id } }, { query = "DELETE FROM `sky_phone_feather_follows` WHERE (`follower_id` = ? AND `following_id` = ?) OR (`follower_id` = ? AND `following_id` = ?)", params = { profile.id, target_id, target_id, profile.id } }, }) then return { success = false, error = "request_failed" } end return { success = true } end) Bridge.Callbacks.Register("sky_phone:feather:report", function(source, data) if not SkyPhone.AllowOperation(source, "feather:report", Config.Feather.ReportsPerDay, 86400) then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end data = type(data) == "table" and data or {} local reason = data.reason local details = trim(data.details) or "" if type(data.id) ~= "string" or not report_reasons[reason] or not valid_text(details, 0, Config.Feather.ReportDetailsMaxLength) then return { success = false, error = "invalid_request" } end local posts = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_posts` WHERE `id` = ? AND `status` = 'published' LIMIT 1", { data.id }) if not posts[1] then return { success = false, error = "post_not_found" } end Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_feather_reports` (`id`, `reporter_id`, `post_id`, `reason`, `details`) VALUES (?, ?, ?, ?, ?)]], { new_id(), profile.id, data.id, reason, details, }) return { success = true } end) end)