Files
sky_phone-sky-systems/sky_phone/source/server/feather.lua
T
2026-08-10 07:33:19 +02:00

648 lines
34 KiB
Lua

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)