mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 17:01:40 +00:00
648 lines
34 KiB
Lua
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)
|