fedipod-server 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -95,8 +95,16 @@ export async function handle(api, ctx) {
95
95
  api.store.setScheduled(sched);
96
96
  return send(200, api.scheduledJson(entry));
97
97
  }
98
- const note = await api.agent.publisher.publishNote(body.status,
99
- { inReplyTo, attachments, visibility, spoilerText });
98
+ let note;
99
+ try {
100
+ note = await api.agent.publisher.publishNote(body.status,
101
+ { inReplyTo, attachments, visibility, spoilerText });
102
+ } catch (e) {
103
+ // A direct message nobody could be found for: the sender's mistake to
104
+ // fix, said in the client, not a fault of the server.
105
+ if (e.code === 'unaddressed') return send(422, { error: e.message });
106
+ throw e;
107
+ }
100
108
  const s = api.store.getStatuses().find(x => x.noteId === note.id);
101
109
  return send(200, api.status(s));
102
110
  }
@@ -182,6 +190,12 @@ export async function handle(api, ctx) {
182
190
  const mContext = /^\/api\/v1\/statuses\/([a-f0-9]+)\/context$/.exec(pathname);
183
191
  if (mContext) {
184
192
  const noteUrl = api.lookup(mContext[1]).s?.noteId;
193
+ // A Bluesky post's conversation lives on Bluesky: ask for it now, so the
194
+ // replies (and their pictures) are in the mirror before the thread is built.
195
+ const opened = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
196
+ if (opened?.kind === 'bsky' && typeof api.agent?.bskyfeed?.mirrorThread === 'function') {
197
+ await api.agent.bskyfeed.mirrorThread(noteUrl);
198
+ }
185
199
  const all = api.store.getStatuses();
186
200
  const byId = new Map(all.map(s => [s.noteId, s]));
187
201
  const ancestors = [];
@@ -18,6 +18,22 @@ export const postUrl = (uri) => {
18
18
  return m ? `https://bsky.app/profile/${m[1]}/post/${m[2]}` : null;
19
19
  };
20
20
 
21
+ // Every place a post view keeps pictures: a plain image post, the media half
22
+ // of a quote-with-media, and a link card's thumbnail. Bluesky's view types
23
+ // differ by shape, not by a flag, so each is read where it lives.
24
+ export function attachmentsOf(post) {
25
+ const e = post?.embed || {};
26
+ const images = [...(e.images || []), ...(e.media?.images || [])]
27
+ .filter(i => i?.fullsize)
28
+ .map(i => ({ url: i.fullsize, mediaType: 'image/jpeg', description: i.alt || '' }));
29
+ const card = e.external?.thumb || e.media?.external?.thumb;
30
+ if (card) {
31
+ const ext = e.external || e.media.external;
32
+ images.push({ url: card, mediaType: 'image/jpeg', description: ext.title || ext.uri || '' });
33
+ }
34
+ return images;
35
+ }
36
+
21
37
  export class BskyFeed {
22
38
  constructor({ store, atproto, log = console.log, onNotification = null }) {
23
39
  Object.assign(this, { store, atproto, log, onNotification });
@@ -73,14 +89,27 @@ export class BskyFeed {
73
89
  }
74
90
 
75
91
  // One Bluesky post into the statuses index. Returns its noteId, new or not.
92
+ // A post seen before is completed, not duplicated: a reply first met as a
93
+ // bare notification record learns its parent and its pictures from the
94
+ // full view when that arrives.
76
95
  _mirrorPost(post, { via = null } = {}) {
77
96
  const noteId = post.uri;
78
- const existing = this.store.getStatuses().some(s => s.noteId === noteId);
79
- if (existing) return { noteId, added: false };
97
+ const inReplyTo = post.record?.reply?.parent?.uri || null;
98
+ const attachments = attachmentsOf(post);
99
+ const existing = this.store.getStatuses().find(s => s.noteId === noteId);
100
+ if (existing) {
101
+ const patch = {
102
+ ...(inReplyTo && !existing.inReplyTo ? { inReplyTo } : {}),
103
+ ...(attachments.length && !existing.attachments?.length ? { attachments } : {}),
104
+ };
105
+ if (Object.keys(patch).length && typeof this.store.updateStatus === 'function') {
106
+ this.store.updateStatus(noteId, patch);
107
+ }
108
+ return { noteId, added: false };
109
+ }
80
110
  const actor = this._rememberAuthor(post.author);
81
111
  if (this.store.isBlocked(actor)) return { noteId, added: false };
82
112
  const text = post.record?.text || '';
83
- const images = post.embed?.images || [];
84
113
  this.store.addStatus({
85
114
  noteId, actor,
86
115
  content: `<p>${text.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]))}</p>`,
@@ -89,13 +118,48 @@ export class BskyFeed {
89
118
  ...(post.cid ? { cid: post.cid } : {}),
90
119
  link: postUrl(noteId),
91
120
  ...(via ? { via } : {}),
92
- ...(images.length ? {
93
- attachments: images.map(i => ({ url: i.fullsize, mediaType: 'image/jpeg', description: i.alt || '' })),
94
- } : {}),
121
+ ...(inReplyTo ? { inReplyTo } : {}),
122
+ ...(attachments.length ? { attachments } : {}),
95
123
  });
96
124
  return { noteId, added: true };
97
125
  }
98
126
 
127
+ // The conversation under one post, asked for when a post is opened: the
128
+ // ancestors it hangs from and the replies beneath it, each mirrored with
129
+ // the reply link the thread view is built from. View cache only, like the
130
+ // timeline. Returns how many posts were new; a thread Bluesky will not
131
+ // give (gone, blocked, rate-limited) adds none and is logged, not thrown.
132
+ async mirrorThread(uri, { depth = 6 } = {}) {
133
+ if (!this.atproto?.connected()) return 0;
134
+ if (this.quietUntil && Date.now() < this.quietUntil) return 0;
135
+ let out;
136
+ try {
137
+ out = await this.atproto.xrpc('app.bsky.feed.getPostThread', { params: { uri, depth, parentHeight: 40 } });
138
+ } catch (e) {
139
+ if (e.status === 429 || e.status >= 500) this._backOff(e.status, null);
140
+ else this.log(`bskyfeed: thread ${uri}: ${e.message}`);
141
+ return 0;
142
+ }
143
+ const self = this.atproto.read()?.did;
144
+ const ownMirrors = new Set(this.store.getStatuses().map(s => s.atproto?.uri).filter(Boolean));
145
+ let added = 0;
146
+ const take = (node) => {
147
+ const post = node?.post;
148
+ if (!post?.uri) return; // notFoundPost, blockedPost
149
+ if (post.author?.did === self && ownMirrors.has(post.uri)) return;
150
+ if (this._mirrorPost(post).added) added++;
151
+ };
152
+ for (let up = out?.thread?.parent; up; up = up.parent) take(up);
153
+ const walk = (node, left) => {
154
+ take(node);
155
+ if (left <= 0) return;
156
+ for (const r of node?.replies || []) walk(r, left - 1);
157
+ };
158
+ walk(out?.thread, depth);
159
+ if (added) this.log(`bskyfeed: +${added} from the thread under ${uri}`);
160
+ return added;
161
+ }
162
+
99
163
  async sweep() {
100
164
  if (!this.atproto?.connected()) return;
101
165
  if (this.quietUntil && Date.now() < this.quietUntil) return;
@@ -142,8 +206,15 @@ export class BskyFeed {
142
206
  this.store.addNotification({ type: 'follow', actor, bsky: true });
143
207
  await this.onNotification?.(n, { actor });
144
208
  } else if (n.reason === 'mention' || n.reason === 'reply') {
145
- const record = { uri: n.uri, cid: n.cid, author: n.author, record: n.record, indexedAt: n.indexedAt };
146
- this._mirrorPost(record);
209
+ // The notification carries the bare record no picture URLs and
210
+ // no view. The post view has both; the record is the fallback when
211
+ // Bluesky will not give the view.
212
+ let view = null;
213
+ try {
214
+ const got = await this.atproto.xrpc('app.bsky.feed.getPosts', { params: { uris: n.uri } });
215
+ view = (got?.posts || [])[0] || null;
216
+ } catch { /* the bare record still stands */ }
217
+ this._mirrorPost(view || { uri: n.uri, cid: n.cid, author: n.author, record: n.record, indexedAt: n.indexedAt });
147
218
  this.store.addNotification({ type: 'mention', actor, noteId: n.uri, bsky: true });
148
219
  await this.onNotification?.(n, { actor });
149
220
  }
@@ -213,7 +213,13 @@ export async function ingestNote(intake, objectId, actor, { via } = {}) {
213
213
  ...(attachments.length ? { attachments } : {}),
214
214
  ...(via ? { via } : {}),
215
215
  });
216
- if (!followed || (note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes))) {
216
+ // A notification for anything addressed to this person: a stranger's
217
+ // post, a reply to one of ours, a direct message, or a post that names
218
+ // us — whoever sent it. A direct message from someone followed used to
219
+ // raise nothing and sat unseen in the private mentions.
220
+ const namesUs = mentions.some((m) => m.href === intake.urls.actor);
221
+ const replyToOurs = !!note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes);
222
+ if (!followed || replyToOurs || direct || namesUs) {
217
223
  intake.store.addNotification({ type: 'mention', actor: author, noteId: note.id });
218
224
  }
219
225
  if (note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes)) {
@@ -83,6 +83,22 @@ export async function mentionsFor(publisher, content, inReplyTo) {
83
83
  return mentions;
84
84
  }
85
85
 
86
+ // A direct post goes only to the people it names, so one that names nobody
87
+ // the agent can find would be kept in the private container and delivered
88
+ // to no one, with nothing said. Refused instead, before anything is written,
89
+ // with the handles that did not resolve — that is what the sender can act on.
90
+ export function assertDirectAddressed(content, mentions) {
91
+ const wanted = [...new Set(wire.mentionsIn(content))];
92
+ const missing = wanted.filter((h) => !mentions.some((m) => m.handle === h));
93
+ let why = null;
94
+ if (!wanted.length) why = 'a direct message names who it is for — mention them as @name@host';
95
+ else if (missing.length) why = `could not find ${missing.map((h) => '@' + h).join(', ')} — the direct message was not sent`;
96
+ if (!why) return;
97
+ const e = new Error(why);
98
+ e.code = 'unaddressed';
99
+ throw e;
100
+ }
101
+
86
102
  export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
87
103
  const { urls } = publisher;
88
104
  const priv = visibility === 'private' || visibility === 'direct';
@@ -93,6 +109,7 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
93
109
  const published = new Date().toISOString();
94
110
  const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
95
111
  const mentions = await publisher._mentionsFor(content, inReplyTo);
112
+ if (visibility === 'direct') assertDirectAddressed(content, mentions);
96
113
  const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
97
114
  visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
98
115
 
@@ -9,6 +9,7 @@ import crypto from 'node:crypto';
9
9
  import * as wire from '../wire.mjs';
10
10
  import * as polls from '../polls.mjs';
11
11
  import * as podNotes from '../../pod/notes.mjs';
12
+ import { assertDirectAddressed } from './notes.mjs';
12
13
 
13
14
  // How long a poll gathers votes before its Question is rewritten. Every vote
14
15
  // changes a number other servers re-read, and rewriting per vote would make a
@@ -52,6 +53,7 @@ export async function publishQuestion(publisher, content, { options = [], multip
52
53
  const published = new Date().toISOString();
53
54
  const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
54
55
  const mentions = await publisher._mentionsFor(content, inReplyTo);
56
+ if (visibility === 'direct') assertDirectAddressed(content, mentions);
55
57
  const poll = {
56
58
  multiple: !!multiple,
57
59
  expiresAt: expiresAt || null,
@@ -588,8 +588,10 @@ async function route(request, ctx) {
588
588
  if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
589
589
  if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
590
590
  const policy = await policyFor(rec, ctx.fetchImpl || fetch);
591
- const { status } = await handleDelivery(request, identFor(rec, policy),
591
+ const { status, reason } = await handleDelivery(request, identFor(rec, policy),
592
592
  { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl });
593
+ // One line per delivery, so "did it arrive at the door" has an answer.
594
+ console.log(`door @${up.handle}: delivery → ${status} (${reason})`);
593
595
  return { status, headers: {}, body: '' };
594
596
  }
595
597
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -55946,6 +55946,17 @@ async function mentionsFor(publisher, content, inReplyTo) {
55946
55946
  }
55947
55947
  return mentions;
55948
55948
  }
55949
+ function assertDirectAddressed(content, mentions) {
55950
+ const wanted = [...new Set(mentionsIn(content))];
55951
+ const missing = wanted.filter((h) => !mentions.some((m) => m.handle === h));
55952
+ let why = null;
55953
+ if (!wanted.length) why = "a direct message names who it is for \u2014 mention them as @name@host";
55954
+ else if (missing.length) why = `could not find ${missing.map((h) => "@" + h).join(", ")} \u2014 the direct message was not sent`;
55955
+ if (!why) return;
55956
+ const e = new Error(why);
55957
+ e.code = "unaddressed";
55958
+ throw e;
55959
+ }
55949
55960
  async function publishNote(publisher, content, { inReplyTo, attachments, visibility = "public", spoilerText = null } = {}) {
55950
55961
  const { urls } = publisher;
55951
55962
  const priv = visibility === "private" || visibility === "direct";
@@ -55956,6 +55967,7 @@ async function publishNote(publisher, content, { inReplyTo, attachments, visibil
55956
55967
  const published = (/* @__PURE__ */ new Date()).toISOString();
55957
55968
  const slug = published.slice(0, 10) + "-" + node_crypto_default.randomBytes(4).toString("hex");
55958
55969
  const mentions = await publisher._mentionsFor(content, inReplyTo);
55970
+ if (visibility === "direct") assertDirectAddressed(content, mentions);
55959
55971
  const note = noteDoc({
55960
55972
  urls,
55961
55973
  slug,
@@ -56157,6 +56169,7 @@ async function publishQuestion(publisher, content, {
56157
56169
  const published = (/* @__PURE__ */ new Date()).toISOString();
56158
56170
  const slug = published.slice(0, 10) + "-" + node_crypto_default.randomBytes(4).toString("hex");
56159
56171
  const mentions = await publisher._mentionsFor(content, inReplyTo);
56172
+ if (visibility === "direct") assertDirectAddressed(content, mentions);
56160
56173
  const poll = {
56161
56174
  multiple: !!multiple,
56162
56175
  expiresAt: expiresAt || null,
@@ -57471,9 +57484,9 @@ async function onUpdate(intake, activity, actor) {
57471
57484
  const note = await intake.fetchAP(objectId);
57472
57485
  if (!note) throw new Error(`cannot refetch ${objectId} \u2014 will retry`);
57473
57486
  if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
57474
- const { attachmentsOf: attachmentsOf2, titledContent: titledContent2 } = await Promise.resolve().then(() => (init_wire(), wire_exports));
57487
+ const { attachmentsOf: attachmentsOf3, titledContent: titledContent2 } = await Promise.resolve().then(() => (init_wire(), wire_exports));
57475
57488
  const content = titledContent2(note);
57476
- const attachments = attachmentsOf2(note);
57489
+ const attachments = attachmentsOf3(note);
57477
57490
  const freshPoll = pollOf(note);
57478
57491
  const freshEmojis = emojisOf(note);
57479
57492
  intake.store.updateStatus(objectId, {
@@ -57607,8 +57620,8 @@ async function ingestNote(intake, objectId, actor, { via } = {}) {
57607
57620
  const note = await intake.fetchAP(objectId);
57608
57621
  if (!note) return `object fetch failed (${objectId})`;
57609
57622
  if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
57610
- const { attachmentsOf: attachmentsOf2, titledContent: titledContent2 } = await Promise.resolve().then(() => (init_wire(), wire_exports));
57611
- const attachments = attachmentsOf2(note);
57623
+ const { attachmentsOf: attachmentsOf3, titledContent: titledContent2 } = await Promise.resolve().then(() => (init_wire(), wire_exports));
57624
+ const attachments = attachmentsOf3(note);
57612
57625
  const content = titledContent2(note);
57613
57626
  const author = authorOf(note, actor);
57614
57627
  if (!author) return `object names an author its origin does not vouch for (${objectId})`;
@@ -57652,7 +57665,9 @@ async function ingestNote(intake, objectId, actor, { via } = {}) {
57652
57665
  ...attachments.length ? { attachments } : {},
57653
57666
  ...via ? { via } : {}
57654
57667
  });
57655
- if (!followed || note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes)) {
57668
+ const namesUs = mentions.some((m) => m.href === intake.urls.actor);
57669
+ const replyToOurs = !!note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes);
57670
+ if (!followed || replyToOurs || direct || namesUs) {
57656
57671
  intake.store.addNotification({ type: "mention", actor: author, noteId: note.id });
57657
57672
  }
57658
57673
  if (note.inReplyTo && String(note.inReplyTo).startsWith(intake.urls.notes)) {
@@ -64528,6 +64543,16 @@ var postUrl = (uri) => {
64528
64543
  const m = String(uri).match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/);
64529
64544
  return m ? `https://bsky.app/profile/${m[1]}/post/${m[2]}` : null;
64530
64545
  };
64546
+ function attachmentsOf2(post) {
64547
+ const e = post?.embed || {};
64548
+ const images = [...e.images || [], ...e.media?.images || []].filter((i) => i?.fullsize).map((i) => ({ url: i.fullsize, mediaType: "image/jpeg", description: i.alt || "" }));
64549
+ const card = e.external?.thumb || e.media?.external?.thumb;
64550
+ if (card) {
64551
+ const ext = e.external || e.media.external;
64552
+ images.push({ url: card, mediaType: "image/jpeg", description: ext.title || ext.uri || "" });
64553
+ }
64554
+ return images;
64555
+ }
64531
64556
  var BskyFeed = class {
64532
64557
  constructor({ store, atproto, log: log2 = console.log, onNotification = null }) {
64533
64558
  Object.assign(this, { store, atproto, log: log2, onNotification });
@@ -64583,14 +64608,27 @@ var BskyFeed = class {
64583
64608
  return url;
64584
64609
  }
64585
64610
  // One Bluesky post into the statuses index. Returns its noteId, new or not.
64611
+ // A post seen before is completed, not duplicated: a reply first met as a
64612
+ // bare notification record learns its parent and its pictures from the
64613
+ // full view when that arrives.
64586
64614
  _mirrorPost(post, { via = null } = {}) {
64587
64615
  const noteId = post.uri;
64588
- const existing = this.store.getStatuses().some((s) => s.noteId === noteId);
64589
- if (existing) return { noteId, added: false };
64616
+ const inReplyTo = post.record?.reply?.parent?.uri || null;
64617
+ const attachments = attachmentsOf2(post);
64618
+ const existing = this.store.getStatuses().find((s) => s.noteId === noteId);
64619
+ if (existing) {
64620
+ const patch = {
64621
+ ...inReplyTo && !existing.inReplyTo ? { inReplyTo } : {},
64622
+ ...attachments.length && !existing.attachments?.length ? { attachments } : {}
64623
+ };
64624
+ if (Object.keys(patch).length && typeof this.store.updateStatus === "function") {
64625
+ this.store.updateStatus(noteId, patch);
64626
+ }
64627
+ return { noteId, added: false };
64628
+ }
64590
64629
  const actor = this._rememberAuthor(post.author);
64591
64630
  if (this.store.isBlocked(actor)) return { noteId, added: false };
64592
64631
  const text = post.record?.text || "";
64593
- const images = post.embed?.images || [];
64594
64632
  this.store.addStatus({
64595
64633
  noteId,
64596
64634
  actor,
@@ -64600,12 +64638,46 @@ var BskyFeed = class {
64600
64638
  ...post.cid ? { cid: post.cid } : {},
64601
64639
  link: postUrl(noteId),
64602
64640
  ...via ? { via } : {},
64603
- ...images.length ? {
64604
- attachments: images.map((i) => ({ url: i.fullsize, mediaType: "image/jpeg", description: i.alt || "" }))
64605
- } : {}
64641
+ ...inReplyTo ? { inReplyTo } : {},
64642
+ ...attachments.length ? { attachments } : {}
64606
64643
  });
64607
64644
  return { noteId, added: true };
64608
64645
  }
64646
+ // The conversation under one post, asked for when a post is opened: the
64647
+ // ancestors it hangs from and the replies beneath it, each mirrored with
64648
+ // the reply link the thread view is built from. View cache only, like the
64649
+ // timeline. Returns how many posts were new; a thread Bluesky will not
64650
+ // give (gone, blocked, rate-limited) adds none and is logged, not thrown.
64651
+ async mirrorThread(uri, { depth = 6 } = {}) {
64652
+ if (!this.atproto?.connected()) return 0;
64653
+ if (this.quietUntil && Date.now() < this.quietUntil) return 0;
64654
+ let out;
64655
+ try {
64656
+ out = await this.atproto.xrpc("app.bsky.feed.getPostThread", { params: { uri, depth, parentHeight: 40 } });
64657
+ } catch (e) {
64658
+ if (e.status === 429 || e.status >= 500) this._backOff(e.status, null);
64659
+ else this.log(`bskyfeed: thread ${uri}: ${e.message}`);
64660
+ return 0;
64661
+ }
64662
+ const self2 = this.atproto.read()?.did;
64663
+ const ownMirrors = new Set(this.store.getStatuses().map((s) => s.atproto?.uri).filter(Boolean));
64664
+ let added = 0;
64665
+ const take2 = (node) => {
64666
+ const post = node?.post;
64667
+ if (!post?.uri) return;
64668
+ if (post.author?.did === self2 && ownMirrors.has(post.uri)) return;
64669
+ if (this._mirrorPost(post).added) added++;
64670
+ };
64671
+ for (let up = out?.thread?.parent; up; up = up.parent) take2(up);
64672
+ const walk = (node, left) => {
64673
+ take2(node);
64674
+ if (left <= 0) return;
64675
+ for (const r of node?.replies || []) walk(r, left - 1);
64676
+ };
64677
+ walk(out?.thread, depth);
64678
+ if (added) this.log(`bskyfeed: +${added} from the thread under ${uri}`);
64679
+ return added;
64680
+ }
64609
64681
  async sweep() {
64610
64682
  if (!this.atproto?.connected()) return;
64611
64683
  if (this.quietUntil && Date.now() < this.quietUntil) return;
@@ -64644,8 +64716,13 @@ var BskyFeed = class {
64644
64716
  this.store.addNotification({ type: "follow", actor, bsky: true });
64645
64717
  await this.onNotification?.(n, { actor });
64646
64718
  } else if (n.reason === "mention" || n.reason === "reply") {
64647
- const record = { uri: n.uri, cid: n.cid, author: n.author, record: n.record, indexedAt: n.indexedAt };
64648
- this._mirrorPost(record);
64719
+ let view = null;
64720
+ try {
64721
+ const got = await this.atproto.xrpc("app.bsky.feed.getPosts", { params: { uris: n.uri } });
64722
+ view = (got?.posts || [])[0] || null;
64723
+ } catch {
64724
+ }
64725
+ this._mirrorPost(view || { uri: n.uri, cid: n.cid, author: n.author, record: n.record, indexedAt: n.indexedAt });
64649
64726
  this.store.addNotification({ type: "mention", actor, noteId: n.uri, bsky: true });
64650
64727
  await this.onNotification?.(n, { actor });
64651
64728
  }
@@ -65896,10 +65973,16 @@ async function handle6(api, ctx) {
65896
65973
  api.store.setScheduled(sched);
65897
65974
  return send(200, api.scheduledJson(entry));
65898
65975
  }
65899
- const note = await api.agent.publisher.publishNote(
65900
- body.status,
65901
- { inReplyTo, attachments, visibility, spoilerText }
65902
- );
65976
+ let note;
65977
+ try {
65978
+ note = await api.agent.publisher.publishNote(
65979
+ body.status,
65980
+ { inReplyTo, attachments, visibility, spoilerText }
65981
+ );
65982
+ } catch (e) {
65983
+ if (e.code === "unaddressed") return send(422, { error: e.message });
65984
+ throw e;
65985
+ }
65903
65986
  const s = api.store.getStatuses().find((x) => x.noteId === note.id);
65904
65987
  return send(200, api.status(s));
65905
65988
  }
@@ -65978,6 +66061,10 @@ async function handle6(api, ctx) {
65978
66061
  const mContext = /^\/api\/v1\/statuses\/([a-f0-9]+)\/context$/.exec(pathname);
65979
66062
  if (mContext) {
65980
66063
  const noteUrl = api.lookup(mContext[1]).s?.noteId;
66064
+ const opened = noteUrl && api.store.getStatuses().find((x) => x.noteId === noteUrl);
66065
+ if (opened?.kind === "bsky" && typeof api.agent?.bskyfeed?.mirrorThread === "function") {
66066
+ await api.agent.bskyfeed.mirrorThread(noteUrl);
66067
+ }
65981
66068
  const all = api.store.getStatuses();
65982
66069
  const byId = new Map(all.map((s) => [s.noteId, s]));
65983
66070
  const ancestors = [];
@@ -66413,8 +66500,8 @@ var TagFeed = class {
66413
66500
  await this.intake.fetchAP(author).catch(() => {
66414
66501
  });
66415
66502
  }
66416
- const { attachmentsOf: attachmentsOf2, titledContent: titledContent2 } = await Promise.resolve().then(() => (init_wire(), wire_exports));
66417
- const attachments = attachmentsOf2(note);
66503
+ const { attachmentsOf: attachmentsOf3, titledContent: titledContent2 } = await Promise.resolve().then(() => (init_wire(), wire_exports));
66504
+ const attachments = attachmentsOf3(note);
66418
66505
  this.store.addStatus({
66419
66506
  noteId,
66420
66507
  actor: author,