mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 07:01:39 +00:00
ADD - implement Feather social app
This commit is contained in:
@@ -0,0 +1,634 @@
|
||||
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: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)
|
||||
Reference in New Issue
Block a user