Skip to content

Commit 8bcce5a

Browse files
tomlarkworthyclaude
authored andcommitted
Slack-Colibri bridge: backfill reactions
Third pass after top-level + replies publishes social.colibri.reaction records on the bot's repo for each (target_message, emoji_name) pair found in the Slack dump. Deterministic rkey from (message_ts, emoji_name) keeps upsert idempotent. Multi-reactor counts are not preserved at the reaction-record level (one record per emoji per target; all author from the bot) — the per- user data is retained losslessly in slackRaw and is recoverable once an upstream per-record author override lands. Emoji shortcode → unicode via the same emoji-data.js map used by the message text walker; unknown / custom workspace emojis fall through as `:name:`. Validated: 20 reactions across last week's backfill, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6053054 commit 8bcce5a

1 file changed

Lines changed: 79 additions & 2 deletions

File tree

pages/slack-colibri-bridge.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@ function hash10(s: string): number {
137137
for (const c of s) h = (h * 31 + c.charCodeAt(0)) | 0;
138138
return Math.abs(h) & 0x3ff;
139139
}
140+
// Reaction rkey: synthesise time from the *message* ts so reactions live next to
141+
// their target in TID order; clockId distinguishes the emoji. With 10 bits of
142+
// clockId space and a small number of distinct emojis per message, collisions
143+
// are rare; collisions just merge two emoji into one reaction record, which
144+
// `slackRaw` can correct if we re-derive.
145+
function tidForReaction(messageTs: string, emojiName: string) {
146+
return tidFromSlackTs(messageTs, hash10(`react:${emojiName}`));
147+
}
140148
function colibriChannelRkey(slackChannelId: string): string {
141149
const ch = channelOf.get(slackChannelId);
142150
if (!ch) throw new Error(`unknown slack channel ${slackChannelId}`);
@@ -380,6 +388,37 @@ function buildMessage(m: any, channelRkey: string, parentRkey?: string) {
380388
};
381389
}
382390

391+
function emojiForReaction(name: string): string {
392+
// Strip Slack ":name::skin-tone-X:" → look up base name, accept the loss of skin tone in v0.
393+
const baseName = name.split("::")[0];
394+
return EMOJI_MAP.get(baseName) ?? `:${name}:`;
395+
}
396+
397+
// Walk a message's `reactions` array → one reaction record per emoji (per
398+
// message). Multiple Slack users with the same emoji collapse into one record
399+
// (they'd all author from the bot anyway and the appview likely dedupes by
400+
// (author, emoji, target)). Multi-reactor count is preserved losslessly in
401+
// `slackRaw`.
402+
function reactionsFor(m: any, targetMessageRkey: string) {
403+
const out: { rkey: string; record: any; userCount: number; name: string; emoji: string }[] = [];
404+
for (const r of m.reactions ?? []) {
405+
if (!r?.name) continue;
406+
const emoji = emojiForReaction(r.name);
407+
out.push({
408+
rkey: tidForReaction(m.ts, r.name),
409+
name: r.name,
410+
emoji,
411+
userCount: (r.users ?? []).length || r.count || 1,
412+
record: {
413+
$type: "social.colibri.reaction",
414+
emoji,
415+
targetMessage: targetMessageRkey,
416+
},
417+
});
418+
}
419+
return out;
420+
}
421+
383422
function legacyTextFallback(raw: string, b: FacetBuilder) {
384423
const decoded = raw
385424
.replace(/<([^>|]+)\|([^>]+)>/g, "$2")
@@ -475,7 +514,9 @@ const fmtRow = (
475514
) => {
476515
const tags = `${built.hasBlocks ? "B" : "."}${built.facetCount.toString().padStart(2, " ")}`;
477516
const parentCol = parent ? `parent=${parent}` : " ";
478-
return ` ${m.ts} ${(m.channel_name || "?").padEnd(20)} ${built.rkey} ${parentCol} ${tags} '${built.record.text.slice(0, 70).replace(/\n/g, " ")}…'`;
517+
const rxCount = (m.reactions ?? []).length;
518+
const rxTag = rxCount ? `+${rxCount}r` : " ";
519+
return ` ${m.ts} ${(m.channel_name || "?").padEnd(20)} ${built.rkey} ${parentCol} ${tags} ${rxTag} '${built.record.text.slice(0, 70).replace(/\n/g, " ")}…'`;
479520
};
480521

481522
console.log("");
@@ -490,6 +531,26 @@ for (const m of replies) {
490531
console.log(fmtRow(m, buildMessage(m, channelMap[m.channel_id], parent), parent));
491532
}
492533

534+
const allWithReactions: { m: any; targetRkey: string }[] = [];
535+
for (const m of tops)
536+
if (m.reactions?.length)
537+
allWithReactions.push({ m, targetRkey: tidFromSlackTs(m.ts) });
538+
for (const m of replies)
539+
if (m.reactions?.length)
540+
allWithReactions.push({ m, targetRkey: tidFromSlackTs(m.ts) });
541+
542+
if (allWithReactions.length > 0) {
543+
console.log("");
544+
console.log("REACTIONS:");
545+
for (const { m, targetRkey } of allWithReactions) {
546+
for (const r of reactionsFor(m, targetRkey)) {
547+
console.log(
548+
` ${m.ts} target=${targetRkey} rkey=${r.rkey} ${r.emoji} (:${r.name}: ×${r.userCount})`,
549+
);
550+
}
551+
}
552+
}
553+
493554
if (dryRun) {
494555
console.log("");
495556
console.log("(dry-run; pass --live to publish)");
@@ -614,4 +675,20 @@ for (const m of replies) {
614675
}
615676

616677
console.error("");
617-
console.error(`done: ${okT} top-level, ${okR} replies, ${failT + failR} failed`);
678+
console.error("reactions…");
679+
let okX = 0, failX = 0;
680+
for (const { m, targetRkey } of allWithReactions) {
681+
for (const r of reactionsFor(m, targetRkey)) {
682+
try {
683+
await put("social.colibri.reaction", r.rkey, r.record);
684+
okX++;
685+
} catch (e) {
686+
failX++;
687+
console.error(` fail ${m.ts} ${r.name}: ${e}`);
688+
}
689+
if (delayMs > 0) await new Promise((rs) => setTimeout(rs, delayMs));
690+
}
691+
}
692+
693+
console.error("");
694+
console.error(`done: ${okT} top-level, ${okR} replies, ${okX} reactions, ${failT + failR + failX} failed`);

0 commit comments

Comments
 (0)