mastodon api: add GET /api/v1/accounts/:id/boosts endpoint
Returns own boosts from ap_interactions + ap_timeline as Mastodon-compatible status array with reblog wrappers. Chronicle PAI uses this to archive all own boost activity from Indiekit instances. Params: limit, since_date, max_date (ISO 8601)
This commit is contained in:
@@ -503,6 +503,119 @@ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read",
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── GET /api/v1/accounts/:id/boosts ────────────────────────────────────────
|
||||||
|
// Chronicle PAI extension — returns own boosts from ap_interactions + ap_timeline.
|
||||||
|
// ap_interactions stores {objectUrl, createdAt} for each own Announce activity.
|
||||||
|
// ap_timeline stores the boosted post (with its original author).
|
||||||
|
// Returns Mastodon-compatible status array with reblog wrapper (like /statuses).
|
||||||
|
|
||||||
|
router.get("/api/v1/accounts/:id/boosts", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const collections = req.app.locals.mastodonCollections;
|
||||||
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||||
|
const limit = parseLimit(req.query.limit);
|
||||||
|
|
||||||
|
// Pagination and since filter
|
||||||
|
const maxDate = req.query.max_date ? new Date(req.query.max_date) : null;
|
||||||
|
const sinceDate = req.query.since_date ? new Date(req.query.since_date) : null;
|
||||||
|
|
||||||
|
// Build filter for ap_interactions
|
||||||
|
const interactionFilter = { type: "boost" };
|
||||||
|
if (sinceDate) interactionFilter.createdAt = { ...interactionFilter.createdAt, $gt: sinceDate.toISOString() };
|
||||||
|
if (maxDate) interactionFilter.createdAt = { ...interactionFilter.createdAt, $lt: maxDate.toISOString() };
|
||||||
|
|
||||||
|
const interactions = await collections.ap_interactions
|
||||||
|
.find(interactionFilter)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (interactions.length === 0) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up each boosted post in ap_timeline by objectUrl
|
||||||
|
const objectUrls = interactions.map((i) => i.objectUrl);
|
||||||
|
const timelineItems = await collections.ap_timeline
|
||||||
|
.find({ uid: { $in: objectUrls } })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
// Build lookup map for O(1) access
|
||||||
|
const timelineMap = new Map(timelineItems.map((item) => [item.uid, item]));
|
||||||
|
|
||||||
|
// Get own actor profile for the boost wrapper account
|
||||||
|
const profile = await collections.ap_profile.findOne({});
|
||||||
|
|
||||||
|
const statuses = interactions.flatMap((interaction) => {
|
||||||
|
const original = timelineMap.get(interaction.objectUrl);
|
||||||
|
if (!original) return []; // post not in local timeline, skip
|
||||||
|
|
||||||
|
const innerStatus = serializeStatus(original, {
|
||||||
|
baseUrl,
|
||||||
|
favouritedIds: new Set(),
|
||||||
|
rebloggedIds: new Set(),
|
||||||
|
bookmarkedIds: new Set(),
|
||||||
|
pinnedIds: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap in boost envelope — outer status represents the boost action
|
||||||
|
return [{
|
||||||
|
id: `boost-${interaction._id || interaction.objectUrl}`,
|
||||||
|
created_at: interaction.createdAt,
|
||||||
|
in_reply_to_id: null,
|
||||||
|
in_reply_to_account_id: null,
|
||||||
|
sensitive: false,
|
||||||
|
spoiler_text: "",
|
||||||
|
visibility: "public",
|
||||||
|
language: null,
|
||||||
|
uri: interaction.objectUrl,
|
||||||
|
url: interaction.objectUrl,
|
||||||
|
replies_count: 0,
|
||||||
|
reblogs_count: 0,
|
||||||
|
favourites_count: 0,
|
||||||
|
edited_at: null,
|
||||||
|
favourited: false,
|
||||||
|
reblogged: true,
|
||||||
|
muted: false,
|
||||||
|
bookmarked: false,
|
||||||
|
pinned: false,
|
||||||
|
content: "",
|
||||||
|
filtered: [],
|
||||||
|
reblog: innerStatus,
|
||||||
|
application: null,
|
||||||
|
account: profile ? {
|
||||||
|
id: profile._id?.toString() || "1",
|
||||||
|
username: profile.preferredUsername || "user",
|
||||||
|
acct: profile.preferredUsername || "user",
|
||||||
|
display_name: profile.name || "",
|
||||||
|
url: profile.url || baseUrl,
|
||||||
|
avatar: profile.icon?.url || "",
|
||||||
|
avatar_static: profile.icon?.url || "",
|
||||||
|
header: "",
|
||||||
|
header_static: "",
|
||||||
|
note: "",
|
||||||
|
emojis: [],
|
||||||
|
fields: [],
|
||||||
|
followers_count: 0,
|
||||||
|
following_count: 0,
|
||||||
|
statuses_count: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
} : null,
|
||||||
|
media_attachments: [],
|
||||||
|
mentions: [],
|
||||||
|
tags: [],
|
||||||
|
emojis: [],
|
||||||
|
card: null,
|
||||||
|
poll: null,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(statuses);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
|
// ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
|
router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user