@emdash-cms/plugin-atproto 0.0.1
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.
- package/package.json +36 -0
- package/src/atproto.ts +408 -0
- package/src/bluesky.ts +185 -0
- package/src/index.ts +42 -0
- package/src/sandbox-entry.ts +671 -0
- package/src/standard-site.ts +195 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox Entry Point -- AT Protocol
|
|
3
|
+
*
|
|
4
|
+
* Canonical plugin implementation using the standard format.
|
|
5
|
+
* The bundler (tsdown) inlines all local imports from atproto.ts,
|
|
6
|
+
* bluesky.ts, and standard-site.ts into a single self-contained file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { definePlugin } from "emdash";
|
|
10
|
+
import type { PluginContext } from "emdash";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
ensureSession,
|
|
14
|
+
createRecord,
|
|
15
|
+
putRecord,
|
|
16
|
+
deleteRecord,
|
|
17
|
+
rkeyFromUri,
|
|
18
|
+
uploadBlob,
|
|
19
|
+
requireHttp,
|
|
20
|
+
} from "./atproto.js";
|
|
21
|
+
import { buildBskyPost } from "./bluesky.js";
|
|
22
|
+
import { buildPublication, buildDocument } from "./standard-site.js";
|
|
23
|
+
|
|
24
|
+
// ── Types ───────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
interface SyndicationRecord {
|
|
27
|
+
collection: string;
|
|
28
|
+
contentId: string;
|
|
29
|
+
atUri: string;
|
|
30
|
+
atCid: string;
|
|
31
|
+
bskyPostUri?: string;
|
|
32
|
+
bskyPostCid?: string;
|
|
33
|
+
publishedAt: string;
|
|
34
|
+
lastSyncedAt: string;
|
|
35
|
+
status: "synced" | "error" | "pending";
|
|
36
|
+
errorMessage?: string;
|
|
37
|
+
retryCount?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
async function isCollectionAllowed(ctx: PluginContext, collection: string): Promise<boolean> {
|
|
43
|
+
const setting = await ctx.kv.get<string>("settings:collections");
|
|
44
|
+
if (!setting || setting.trim() === "") return true;
|
|
45
|
+
const allowed = setting.split(",").map((s) => s.trim().toLowerCase());
|
|
46
|
+
return allowed.includes(collection.toLowerCase());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function syndicateContent(
|
|
50
|
+
ctx: PluginContext,
|
|
51
|
+
collection: string,
|
|
52
|
+
contentId: string,
|
|
53
|
+
content: Record<string, unknown>,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const storageKey = `${collection}:${contentId}`;
|
|
56
|
+
const existing = (await ctx.storage.records!.get(storageKey)) as SyndicationRecord | null;
|
|
57
|
+
|
|
58
|
+
if (existing && existing.status === "synced") {
|
|
59
|
+
const syncOnUpdate = (await ctx.kv.get<boolean>("settings:syncOnUpdate")) ?? true;
|
|
60
|
+
if (!syncOnUpdate) return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
|
|
64
|
+
if (!siteUrl) throw new Error("Site URL not configured");
|
|
65
|
+
|
|
66
|
+
const publicationUri = await ctx.kv.get<string>("state:publicationUri");
|
|
67
|
+
if (!publicationUri)
|
|
68
|
+
throw new Error("Publication record not created yet. Use Sync Publication first.");
|
|
69
|
+
|
|
70
|
+
const { accessJwt, did, pdsHost } = await ensureSession(ctx);
|
|
71
|
+
|
|
72
|
+
// Upload cover image if present
|
|
73
|
+
let coverImageBlob;
|
|
74
|
+
const rawCoverImage = content.cover_image as string | undefined;
|
|
75
|
+
if (rawCoverImage) {
|
|
76
|
+
let imageUrl = rawCoverImage;
|
|
77
|
+
if (imageUrl.startsWith("/")) imageUrl = `${siteUrl}${imageUrl}`;
|
|
78
|
+
|
|
79
|
+
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
|
|
80
|
+
try {
|
|
81
|
+
const http = requireHttp(ctx);
|
|
82
|
+
const imageRes = await http.fetch(imageUrl);
|
|
83
|
+
if (imageRes.ok) {
|
|
84
|
+
const bytes = await imageRes.arrayBuffer();
|
|
85
|
+
if (bytes.byteLength <= 1_000_000) {
|
|
86
|
+
const mimeType = imageRes.headers.get("content-type") || "image/jpeg";
|
|
87
|
+
coverImageBlob = await uploadBlob(ctx, pdsHost, accessJwt, bytes, mimeType);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
ctx.log.warn("Failed to upload cover image, skipping", error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let bskyPostRef: { uri: string; cid: string } | undefined;
|
|
97
|
+
|
|
98
|
+
if (existing && existing.atUri) {
|
|
99
|
+
const rkey = rkeyFromUri(existing.atUri);
|
|
100
|
+
const doc = buildDocument({
|
|
101
|
+
publicationUri,
|
|
102
|
+
content,
|
|
103
|
+
coverImageBlob,
|
|
104
|
+
bskyPostRef:
|
|
105
|
+
existing.bskyPostUri && existing.bskyPostCid
|
|
106
|
+
? { uri: existing.bskyPostUri, cid: existing.bskyPostCid }
|
|
107
|
+
: undefined,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await putRecord(
|
|
111
|
+
ctx,
|
|
112
|
+
pdsHost,
|
|
113
|
+
accessJwt,
|
|
114
|
+
did,
|
|
115
|
+
"site.standard.document",
|
|
116
|
+
rkey,
|
|
117
|
+
doc,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await ctx.storage.records!.put(storageKey, {
|
|
121
|
+
collection: existing.collection,
|
|
122
|
+
contentId: existing.contentId,
|
|
123
|
+
atUri: result.uri,
|
|
124
|
+
atCid: result.cid,
|
|
125
|
+
bskyPostUri: existing.bskyPostUri,
|
|
126
|
+
bskyPostCid: existing.bskyPostCid,
|
|
127
|
+
publishedAt: existing.publishedAt,
|
|
128
|
+
lastSyncedAt: new Date().toISOString(),
|
|
129
|
+
status: "synced",
|
|
130
|
+
retryCount: 0,
|
|
131
|
+
} satisfies SyndicationRecord);
|
|
132
|
+
|
|
133
|
+
ctx.log.info(`Updated AT Protocol document for ${collection}/${contentId}`);
|
|
134
|
+
} else {
|
|
135
|
+
const doc = buildDocument({ publicationUri, content, coverImageBlob });
|
|
136
|
+
const result = await createRecord(ctx, pdsHost, accessJwt, did, "site.standard.document", doc);
|
|
137
|
+
|
|
138
|
+
const enableCrosspost = (await ctx.kv.get<boolean>("settings:enableBskyCrosspost")) ?? true;
|
|
139
|
+
if (enableCrosspost) {
|
|
140
|
+
try {
|
|
141
|
+
const template =
|
|
142
|
+
(await ctx.kv.get<string>("settings:crosspostTemplate")) || "{title}\n\n{url}";
|
|
143
|
+
const langsStr = (await ctx.kv.get<string>("settings:langs")) || "en";
|
|
144
|
+
const langs = langsStr
|
|
145
|
+
.split(",")
|
|
146
|
+
.map((s) => s.trim())
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.slice(0, 3);
|
|
149
|
+
const post = buildBskyPost({
|
|
150
|
+
template,
|
|
151
|
+
content,
|
|
152
|
+
siteUrl,
|
|
153
|
+
thumbBlob: coverImageBlob,
|
|
154
|
+
langs,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const postResult = await createRecord(
|
|
158
|
+
ctx,
|
|
159
|
+
pdsHost,
|
|
160
|
+
accessJwt,
|
|
161
|
+
did,
|
|
162
|
+
"app.bsky.feed.post",
|
|
163
|
+
post,
|
|
164
|
+
);
|
|
165
|
+
bskyPostRef = { uri: postResult.uri, cid: postResult.cid };
|
|
166
|
+
|
|
167
|
+
const rkey = rkeyFromUri(result.uri);
|
|
168
|
+
const updatedDoc = buildDocument({ publicationUri, content, coverImageBlob, bskyPostRef });
|
|
169
|
+
await putRecord(ctx, pdsHost, accessJwt, did, "site.standard.document", rkey, updatedDoc);
|
|
170
|
+
|
|
171
|
+
ctx.log.info(`Cross-posted ${collection}/${contentId} to Bluesky`);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
ctx.log.warn("Failed to cross-post to Bluesky, document still synced", error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await ctx.storage.records!.put(storageKey, {
|
|
178
|
+
collection,
|
|
179
|
+
contentId,
|
|
180
|
+
atUri: result.uri,
|
|
181
|
+
atCid: result.cid,
|
|
182
|
+
bskyPostUri: bskyPostRef?.uri,
|
|
183
|
+
bskyPostCid: bskyPostRef?.cid,
|
|
184
|
+
publishedAt: (content.published_at as string) || new Date().toISOString(),
|
|
185
|
+
lastSyncedAt: new Date().toISOString(),
|
|
186
|
+
status: "synced",
|
|
187
|
+
} satisfies SyndicationRecord);
|
|
188
|
+
|
|
189
|
+
ctx.log.info(`Created AT Protocol document for ${collection}/${contentId}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Plugin definition ───────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
export default definePlugin({
|
|
196
|
+
hooks: {
|
|
197
|
+
"plugin:install": async (_event: unknown, ctx: PluginContext) => {
|
|
198
|
+
ctx.log.info("AT Protocol plugin installed");
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
"content:afterSave": {
|
|
202
|
+
handler: async (
|
|
203
|
+
event: { content: Record<string, unknown>; collection: string; isNew: boolean },
|
|
204
|
+
ctx: PluginContext,
|
|
205
|
+
) => {
|
|
206
|
+
const { content, collection } = event;
|
|
207
|
+
const contentId = typeof content.id === "string" ? content.id : String(content.id);
|
|
208
|
+
const status = content.status as string | undefined;
|
|
209
|
+
|
|
210
|
+
if (status !== "published") return;
|
|
211
|
+
if (!(await isCollectionAllowed(ctx, collection))) return;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await syndicateContent(ctx, collection, contentId, content);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
ctx.log.error(`Failed to syndicate ${collection}/${contentId}`, error);
|
|
217
|
+
|
|
218
|
+
const storageKey = `${collection}:${contentId}`;
|
|
219
|
+
const existing = await ctx.storage.records!.get(storageKey);
|
|
220
|
+
const record = (existing as SyndicationRecord | null) || {
|
|
221
|
+
collection,
|
|
222
|
+
contentId,
|
|
223
|
+
atUri: "",
|
|
224
|
+
atCid: "",
|
|
225
|
+
publishedAt: new Date().toISOString(),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
await ctx.storage.records!.put(storageKey, {
|
|
229
|
+
...record,
|
|
230
|
+
status: "error",
|
|
231
|
+
lastSyncedAt: new Date().toISOString(),
|
|
232
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
233
|
+
retryCount: ((record as SyndicationRecord).retryCount || 0) + 1,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
"content:afterDelete": {
|
|
240
|
+
handler: async (event: { id: string; collection: string }, ctx: PluginContext) => {
|
|
241
|
+
const { id, collection } = event;
|
|
242
|
+
const deleteOnUnpublish = (await ctx.kv.get<boolean>("settings:deleteOnUnpublish")) ?? true;
|
|
243
|
+
if (!deleteOnUnpublish) return;
|
|
244
|
+
|
|
245
|
+
const storageKey = `${collection}:${id}`;
|
|
246
|
+
const existing = (await ctx.storage.records!.get(storageKey)) as SyndicationRecord | null;
|
|
247
|
+
if (!existing || !existing.atUri) return;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const { accessJwt, did, pdsHost } = await ensureSession(ctx);
|
|
251
|
+
const rkey = rkeyFromUri(existing.atUri);
|
|
252
|
+
await deleteRecord(ctx, pdsHost, accessJwt, did, "site.standard.document", rkey);
|
|
253
|
+
|
|
254
|
+
if (existing.bskyPostUri) {
|
|
255
|
+
const postRkey = rkeyFromUri(existing.bskyPostUri);
|
|
256
|
+
await deleteRecord(ctx, pdsHost, accessJwt, did, "app.bsky.feed.post", postRkey);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await ctx.storage.records!.delete(storageKey);
|
|
260
|
+
ctx.log.info(`Deleted AT Protocol records for ${collection}/${id}`);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
ctx.log.error(`Failed to delete AT Protocol records for ${collection}/${id}`, error);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
"page:metadata": async (
|
|
268
|
+
event: { page: { content?: { collection: string; id: string } } },
|
|
269
|
+
ctx: PluginContext,
|
|
270
|
+
) => {
|
|
271
|
+
const pageContent = event.page.content;
|
|
272
|
+
if (!pageContent) return null;
|
|
273
|
+
|
|
274
|
+
const storageKey = `${pageContent.collection}:${pageContent.id}`;
|
|
275
|
+
const record = (await ctx.storage.records!.get(storageKey)) as SyndicationRecord | null;
|
|
276
|
+
|
|
277
|
+
if (!record || !record.atUri || record.status !== "synced") return null;
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
kind: "link" as const,
|
|
281
|
+
rel: "site.standard.document",
|
|
282
|
+
href: record.atUri,
|
|
283
|
+
key: "atproto-document",
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
routes: {
|
|
289
|
+
status: {
|
|
290
|
+
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
|
|
291
|
+
try {
|
|
292
|
+
const handle = await ctx.kv.get<string>("settings:handle");
|
|
293
|
+
const did = await ctx.kv.get<string>("state:did");
|
|
294
|
+
const pubUri = await ctx.kv.get<string>("state:publicationUri");
|
|
295
|
+
const synced = await ctx.storage.records!.count({
|
|
296
|
+
status: "synced",
|
|
297
|
+
});
|
|
298
|
+
const errors = await ctx.storage.records!.count({
|
|
299
|
+
status: "error",
|
|
300
|
+
});
|
|
301
|
+
const pending = await ctx.storage.records!.count({
|
|
302
|
+
status: "pending",
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
configured: !!handle,
|
|
307
|
+
connected: !!did,
|
|
308
|
+
handle: handle || null,
|
|
309
|
+
did: did || null,
|
|
310
|
+
publicationUri: pubUri || null,
|
|
311
|
+
stats: { synced, errors, pending },
|
|
312
|
+
};
|
|
313
|
+
} catch (error) {
|
|
314
|
+
ctx.log.error("Failed to get status", error);
|
|
315
|
+
return {
|
|
316
|
+
configured: false,
|
|
317
|
+
connected: false,
|
|
318
|
+
handle: null,
|
|
319
|
+
did: null,
|
|
320
|
+
publicationUri: null,
|
|
321
|
+
stats: { synced: 0, errors: 0, pending: 0 },
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
"test-connection": {
|
|
328
|
+
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
|
|
329
|
+
try {
|
|
330
|
+
const session = await ensureSession(ctx);
|
|
331
|
+
return {
|
|
332
|
+
success: true,
|
|
333
|
+
did: session.did,
|
|
334
|
+
pdsHost: session.pdsHost,
|
|
335
|
+
};
|
|
336
|
+
} catch (error) {
|
|
337
|
+
return {
|
|
338
|
+
success: false,
|
|
339
|
+
error: error instanceof Error ? error.message : String(error),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
"sync-publication": {
|
|
346
|
+
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
|
|
347
|
+
try {
|
|
348
|
+
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
|
|
349
|
+
const siteName = await ctx.kv.get<string>("settings:siteName");
|
|
350
|
+
if (!siteUrl || !siteName)
|
|
351
|
+
return {
|
|
352
|
+
success: false,
|
|
353
|
+
error: "Site URL and name are required",
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const { accessJwt, did, pdsHost } = await ensureSession(ctx);
|
|
357
|
+
const publication = buildPublication(siteUrl, siteName);
|
|
358
|
+
const existingUri = await ctx.kv.get<string>("state:publicationUri");
|
|
359
|
+
|
|
360
|
+
let result;
|
|
361
|
+
if (existingUri) {
|
|
362
|
+
const rkey = rkeyFromUri(existingUri);
|
|
363
|
+
result = await putRecord(
|
|
364
|
+
ctx,
|
|
365
|
+
pdsHost,
|
|
366
|
+
accessJwt,
|
|
367
|
+
did,
|
|
368
|
+
"site.standard.publication",
|
|
369
|
+
rkey,
|
|
370
|
+
publication,
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
result = await createRecord(
|
|
374
|
+
ctx,
|
|
375
|
+
pdsHost,
|
|
376
|
+
accessJwt,
|
|
377
|
+
did,
|
|
378
|
+
"site.standard.publication",
|
|
379
|
+
publication,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
await ctx.kv.set("state:publicationUri", result.uri);
|
|
384
|
+
await ctx.kv.set("state:publicationCid", result.cid);
|
|
385
|
+
return {
|
|
386
|
+
success: true,
|
|
387
|
+
uri: result.uri,
|
|
388
|
+
cid: result.cid,
|
|
389
|
+
};
|
|
390
|
+
} catch (error) {
|
|
391
|
+
return {
|
|
392
|
+
success: false,
|
|
393
|
+
error: error instanceof Error ? error.message : String(error),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
"recent-syncs": {
|
|
400
|
+
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
|
|
401
|
+
try {
|
|
402
|
+
const result = await ctx.storage.records!.query({
|
|
403
|
+
orderBy: { lastSyncedAt: "desc" },
|
|
404
|
+
limit: 20,
|
|
405
|
+
});
|
|
406
|
+
return {
|
|
407
|
+
items: result.items.map((item: { id: string; data: unknown }) => ({
|
|
408
|
+
id: item.id,
|
|
409
|
+
...(item.data as SyndicationRecord),
|
|
410
|
+
})),
|
|
411
|
+
};
|
|
412
|
+
} catch (error) {
|
|
413
|
+
ctx.log.error("Failed to get recent syncs", error);
|
|
414
|
+
return { items: [] };
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
verification: {
|
|
420
|
+
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
|
|
421
|
+
const pubUri = await ctx.kv.get<string>("state:publicationUri");
|
|
422
|
+
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
|
|
423
|
+
return {
|
|
424
|
+
publicationUri: pubUri || null,
|
|
425
|
+
siteUrl: siteUrl || null,
|
|
426
|
+
wellKnownPath: "/.well-known/site.standard.publication",
|
|
427
|
+
wellKnownContent: pubUri || "(not configured yet)",
|
|
428
|
+
};
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
admin: {
|
|
433
|
+
handler: async (routeCtx: any, ctx: PluginContext) => {
|
|
434
|
+
const interaction = routeCtx.input as {
|
|
435
|
+
type: string;
|
|
436
|
+
page?: string;
|
|
437
|
+
action_id?: string;
|
|
438
|
+
values?: Record<string, unknown>;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
if (interaction.type === "page_load" && interaction.page === "widget:sync-status") {
|
|
442
|
+
return buildSyncWidget(ctx);
|
|
443
|
+
}
|
|
444
|
+
if (interaction.type === "page_load" && interaction.page === "/status") {
|
|
445
|
+
return buildStatusPage(ctx);
|
|
446
|
+
}
|
|
447
|
+
if (interaction.type === "form_submit" && interaction.action_id === "save_settings") {
|
|
448
|
+
return saveSettings(ctx, interaction.values ?? {});
|
|
449
|
+
}
|
|
450
|
+
if (interaction.type === "block_action" && interaction.action_id === "test_connection") {
|
|
451
|
+
return testConnection(ctx);
|
|
452
|
+
}
|
|
453
|
+
return { blocks: [] };
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// ── Block Kit admin helpers ─────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
async function buildSyncWidget(ctx: PluginContext) {
|
|
462
|
+
try {
|
|
463
|
+
const handle = await ctx.kv.get<string>("settings:handle");
|
|
464
|
+
const did = await ctx.kv.get<string>("state:did");
|
|
465
|
+
const synced = await ctx.storage.records!.count({ status: "synced" });
|
|
466
|
+
const errors = await ctx.storage.records!.count({ status: "error" });
|
|
467
|
+
|
|
468
|
+
if (!handle) {
|
|
469
|
+
return {
|
|
470
|
+
blocks: [
|
|
471
|
+
{ type: "context", text: "Not configured -- set your handle in AT Protocol settings." },
|
|
472
|
+
],
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
blocks: [
|
|
478
|
+
{
|
|
479
|
+
type: "fields",
|
|
480
|
+
fields: [
|
|
481
|
+
{ label: "Handle", value: `@${handle}` },
|
|
482
|
+
{ label: "Status", value: did ? "Connected" : "Not connected" },
|
|
483
|
+
{ label: "Synced", value: String(synced) },
|
|
484
|
+
{ label: "Errors", value: String(errors) },
|
|
485
|
+
],
|
|
486
|
+
},
|
|
487
|
+
],
|
|
488
|
+
};
|
|
489
|
+
} catch (error) {
|
|
490
|
+
ctx.log.error("Failed to build sync widget", error);
|
|
491
|
+
return { blocks: [{ type: "context", text: "Failed to load status" }] };
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function buildStatusPage(ctx: PluginContext) {
|
|
496
|
+
try {
|
|
497
|
+
const handle = await ctx.kv.get<string>("settings:handle");
|
|
498
|
+
const appPassword = await ctx.kv.get<string>("settings:appPassword");
|
|
499
|
+
const pdsHost = await ctx.kv.get<string>("settings:pdsHost");
|
|
500
|
+
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
|
|
501
|
+
const enableCrosspost = await ctx.kv.get<boolean>("settings:enableCrosspost");
|
|
502
|
+
const did = await ctx.kv.get<string>("state:did");
|
|
503
|
+
const pubUri = await ctx.kv.get<string>("state:publicationUri");
|
|
504
|
+
|
|
505
|
+
const blocks: unknown[] = [
|
|
506
|
+
{ type: "header", text: "AT Protocol" },
|
|
507
|
+
{
|
|
508
|
+
type: "section",
|
|
509
|
+
text: "Syndicate content to the AT Protocol network (Bluesky, standard.site).",
|
|
510
|
+
},
|
|
511
|
+
{ type: "divider" },
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
if (did) {
|
|
515
|
+
blocks.push({
|
|
516
|
+
type: "banner",
|
|
517
|
+
style: "success",
|
|
518
|
+
text: `Connected as ${handle} (${did})`,
|
|
519
|
+
});
|
|
520
|
+
} else if (handle) {
|
|
521
|
+
blocks.push({
|
|
522
|
+
type: "banner",
|
|
523
|
+
style: "warning",
|
|
524
|
+
text: "Handle configured but not yet connected. Save settings and test the connection.",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
blocks.push({
|
|
529
|
+
type: "form",
|
|
530
|
+
block_id: "atproto-settings",
|
|
531
|
+
fields: [
|
|
532
|
+
{
|
|
533
|
+
type: "text_input",
|
|
534
|
+
action_id: "handle",
|
|
535
|
+
label: "AT Protocol Handle",
|
|
536
|
+
initial_value: handle ?? "",
|
|
537
|
+
},
|
|
538
|
+
{ type: "secret_input", action_id: "appPassword", label: "App Password" },
|
|
539
|
+
{
|
|
540
|
+
type: "text_input",
|
|
541
|
+
action_id: "pdsHost",
|
|
542
|
+
label: "PDS Host",
|
|
543
|
+
initial_value: pdsHost ?? "https://bsky.social",
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
type: "text_input",
|
|
547
|
+
action_id: "siteUrl",
|
|
548
|
+
label: "Site URL",
|
|
549
|
+
initial_value: siteUrl ?? "",
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
type: "toggle",
|
|
553
|
+
action_id: "enableCrosspost",
|
|
554
|
+
label: "Cross-post to Bluesky",
|
|
555
|
+
initial_value: enableCrosspost ?? false,
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
submit: { label: "Save Settings", action_id: "save_settings" },
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
blocks.push({
|
|
562
|
+
type: "actions",
|
|
563
|
+
elements: [
|
|
564
|
+
{
|
|
565
|
+
type: "button",
|
|
566
|
+
text: "Test Connection",
|
|
567
|
+
action_id: "test_connection",
|
|
568
|
+
style: handle && appPassword ? "primary" : undefined,
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
if (did) {
|
|
574
|
+
const result = await ctx.storage.records!.query({
|
|
575
|
+
orderBy: { lastSyncedAt: "desc" },
|
|
576
|
+
limit: 10,
|
|
577
|
+
});
|
|
578
|
+
const items = result.items.map((item: { id: string; data: unknown }) => ({
|
|
579
|
+
id: item.id,
|
|
580
|
+
...(item.data as SyndicationRecord),
|
|
581
|
+
}));
|
|
582
|
+
|
|
583
|
+
if (items.length > 0) {
|
|
584
|
+
blocks.push(
|
|
585
|
+
{ type: "divider" },
|
|
586
|
+
{ type: "header", text: "Recent Syncs" },
|
|
587
|
+
{
|
|
588
|
+
type: "table",
|
|
589
|
+
columns: [
|
|
590
|
+
{ key: "collection", label: "Collection", format: "text" },
|
|
591
|
+
{ key: "contentId", label: "Content", format: "code" },
|
|
592
|
+
{ key: "status", label: "Status", format: "badge" },
|
|
593
|
+
{ key: "lastSyncedAt", label: "Synced", format: "relative_time" },
|
|
594
|
+
],
|
|
595
|
+
rows: items.map((r) => ({
|
|
596
|
+
collection: r.collection,
|
|
597
|
+
contentId: r.contentId,
|
|
598
|
+
status: r.status,
|
|
599
|
+
lastSyncedAt: r.lastSyncedAt,
|
|
600
|
+
})),
|
|
601
|
+
emptyText: "No syncs yet",
|
|
602
|
+
},
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (pubUri) {
|
|
607
|
+
blocks.push(
|
|
608
|
+
{ type: "divider" },
|
|
609
|
+
{ type: "header", text: "Verification" },
|
|
610
|
+
{
|
|
611
|
+
type: "fields",
|
|
612
|
+
fields: [
|
|
613
|
+
{ label: "Publication URI", value: pubUri },
|
|
614
|
+
{ label: "Well-known path", value: "/.well-known/site.standard.publication" },
|
|
615
|
+
],
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
type: "context",
|
|
619
|
+
text: "Add this path to your site to verify ownership on the AT Protocol network.",
|
|
620
|
+
},
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return { blocks };
|
|
626
|
+
} catch (error) {
|
|
627
|
+
ctx.log.error("Failed to build status page", error);
|
|
628
|
+
return { blocks: [{ type: "banner", style: "error", text: "Failed to load settings" }] };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function saveSettings(ctx: PluginContext, values: Record<string, unknown>) {
|
|
633
|
+
try {
|
|
634
|
+
if (typeof values.handle === "string") await ctx.kv.set("settings:handle", values.handle);
|
|
635
|
+
if (typeof values.appPassword === "string" && values.appPassword)
|
|
636
|
+
await ctx.kv.set("settings:appPassword", values.appPassword);
|
|
637
|
+
if (typeof values.pdsHost === "string") await ctx.kv.set("settings:pdsHost", values.pdsHost);
|
|
638
|
+
if (typeof values.siteUrl === "string") await ctx.kv.set("settings:siteUrl", values.siteUrl);
|
|
639
|
+
if (typeof values.enableCrosspost === "boolean")
|
|
640
|
+
await ctx.kv.set("settings:enableCrosspost", values.enableCrosspost);
|
|
641
|
+
|
|
642
|
+
const page = await buildStatusPage(ctx);
|
|
643
|
+
return { ...page, toast: { message: "Settings saved", type: "success" } };
|
|
644
|
+
} catch (error) {
|
|
645
|
+
ctx.log.error("Failed to save settings", error);
|
|
646
|
+
return {
|
|
647
|
+
blocks: [{ type: "banner", style: "error", text: "Failed to save settings" }],
|
|
648
|
+
toast: { message: "Failed to save settings", type: "error" },
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function testConnection(ctx: PluginContext) {
|
|
654
|
+
try {
|
|
655
|
+
const session = await ensureSession(ctx);
|
|
656
|
+
const page = await buildStatusPage(ctx);
|
|
657
|
+
return {
|
|
658
|
+
...page,
|
|
659
|
+
toast: { message: `Connected to ${session.pdsHost} as ${session.did}`, type: "success" },
|
|
660
|
+
};
|
|
661
|
+
} catch (error) {
|
|
662
|
+
const page = await buildStatusPage(ctx);
|
|
663
|
+
return {
|
|
664
|
+
...page,
|
|
665
|
+
toast: {
|
|
666
|
+
message: `Connection failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
667
|
+
type: "error",
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
}
|