From bcdcded5394814affa73c5d9214d27e54ecf93e9 Mon Sep 17 00:00:00 2001 From: Takumi Akimoto <81888693+akimon658@users.noreply.github.com> Date: Tue, 5 May 2026 12:21:57 +0900 Subject: [PATCH] feat: Support replies (#10) * feat: Support replies * Move `buildMessageContent` to service * Refactor message builder * Await `isSelfThread` * fix: Don't add newline if there are no URLs to append --- deno.jsonc | 2 + deno.lock | 12 +++ lib/blueskyClient.ts | 11 +++ lib/buildMessageContent.ts | 103 ------------------------ lib/config.ts | 14 ++++ lib/image.ts | 15 +--- lib/thread.ts | 44 ++++++++++ service/jestream.ts | 17 ++-- service/messageBuilder.ts | 160 +++++++++++++++++++++++++++++++++++++ 9 files changed, 257 insertions(+), 121 deletions(-) create mode 100644 lib/blueskyClient.ts delete mode 100644 lib/buildMessageContent.ts create mode 100644 lib/thread.ts create mode 100644 service/messageBuilder.ts diff --git a/deno.jsonc b/deno.jsonc index cdfabd0..d2722df 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -9,10 +9,12 @@ "semiColons": false }, "imports": { + "@atcute/atproto": "npm:@atcute/atproto@^3.1.11", "@atcute/bluesky": "npm:@atcute/bluesky@^3.3.3", "@atcute/client": "npm:@atcute/client@^4.2.1", "@atcute/jetstream": "npm:@atcute/jetstream@^1.1.2", "@atcute/lexicons": "npm:@atcute/lexicons@^1.3.0", + "@atcute/password-session": "npm:@atcute/password-session@^0.1.0", "@badgateway/oauth2-client": "npm:@badgateway/oauth2-client@^3.3.1", "@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.1.2", "@hey-api/openapi-ts": "npm:@hey-api/openapi-ts@^0.97.0", diff --git a/deno.lock b/deno.lock index fcc779e..5d5b173 100644 --- a/deno.lock +++ b/deno.lock @@ -35,11 +35,13 @@ "jsr:@std/semver@^1.0.6": "1.0.8", "jsr:@std/streams@^1.1.0": "1.1.0", "jsr:@std/uuid@^1.0.9": "1.1.1", + "npm:@atcute/atproto@^3.1.11": "3.1.11", "npm:@atcute/bluesky@^3.3.3": "3.3.3", "npm:@atcute/client@^4.2.1": "4.2.1", "npm:@atcute/jetstream@*": "1.1.2", "npm:@atcute/jetstream@^1.1.2": "1.1.2", "npm:@atcute/lexicons@^1.3.0": "1.3.0", + "npm:@atcute/password-session@0.1": "0.1.0", "npm:@babel/core@^7.28.0": "7.29.0", "npm:@babel/preset-react@^7.27.1": "7.28.5_@babel+core@7.29.0", "npm:@badgateway/oauth2-client@^3.3.1": "3.3.1", @@ -256,6 +258,14 @@ "esm-env" ] }, + "@atcute/password-session@0.1.0": { + "integrity": "sha512-r4iUNT7aQ1J6XXGO+pu39037hFQd0GYEhOuw/aykoNI3HHFLX2t5YyrxWTu5uKMGECk3s7zEgc8B8ol9JsMjRA==", + "dependencies": [ + "@atcute/client", + "@atcute/identity", + "@atcute/lexicons" + ] + }, "@atcute/uint8array@1.1.1": { "integrity": "sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==" }, @@ -1740,10 +1750,12 @@ "jsr:@kysely/kysely@~0.28.16", "jsr:@std/encoding@^1.0.10", "jsr:@std/http@^1.1.0", + "npm:@atcute/atproto@^3.1.11", "npm:@atcute/bluesky@^3.3.3", "npm:@atcute/client@^4.2.1", "npm:@atcute/jetstream@^1.1.2", "npm:@atcute/lexicons@^1.3.0", + "npm:@atcute/password-session@0.1", "npm:@badgateway/oauth2-client@^3.3.1", "npm:@hey-api/openapi-ts@0.97", "npm:@hey-api/vite-plugin@~0.3.1", diff --git a/lib/blueskyClient.ts b/lib/blueskyClient.ts new file mode 100644 index 0000000..1694525 --- /dev/null +++ b/lib/blueskyClient.ts @@ -0,0 +1,11 @@ +import { Client } from "@atcute/client" +import { PasswordSession } from "@atcute/password-session" +import { config } from "./config.ts" + +export const client = new Client({ + handler: await PasswordSession.login({ + service: "https://bsky.social", + identifier: config.blueskyAccountIdentifier, + password: config.blueskyAppPassword, + }), +}) diff --git a/lib/buildMessageContent.ts b/lib/buildMessageContent.ts deleted file mode 100644 index d6e83f8..0000000 --- a/lib/buildMessageContent.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - type AppBskyFeedPost, -} from "@atcute/bluesky" -import { is, parseResourceUri } from "@atcute/lexicons" -import { getTraqMessageIdByAtProtoUri } from "../repository/post.ts" -import { config } from "./config.ts" - -interface BuildMessageParams { - post: AppBskyFeedPost.Main - imageIds?: string[] -} - -const encoder = new TextEncoder() -const decoder = new TextDecoder() - -export const buildMessageContent = async ( - { post, imageIds }: BuildMessageParams, -) => { - let text = post.text - - if (post.facets?.length) { - let textBytes = encoder.encode(post.text) - // Sort facets in reverse order to avoid affecting the byte offsets of subsequent facets - const facets = post.facets.sort((a, b) => - b.index.byteStart - a.index.byteStart - ) - - for (const facet of facets) { - const linkFeature = facet.features.find((f) => - f.$type === "app.bsky.richtext.facet#link" - ) - - if (!linkFeature) { - continue - } - - const uriBytes = encoder.encode(linkFeature.uri) - const newBytes = new Uint8Array( - facet.index.byteStart + uriBytes.length + - (textBytes.length - facet.index.byteEnd), - ) - - newBytes.set(textBytes.subarray(0, facet.index.byteStart), 0) - newBytes.set(uriBytes, facet.index.byteStart) - newBytes.set( - textBytes.subarray(facet.index.byteEnd), - facet.index.byteStart + uriBytes.length, - ) - - textBytes = newBytes - } - - text = decoder.decode(textBytes) - } - - if (imageIds?.length) { - const imageLinks = imageIds.map((id) => `${config.traqBaseUrl}/files/${id}`) - .join("\n") - - text = text ? `${text}\n${imageLinks}` : imageLinks - } - - if ( - is(AppBskyEmbedRecord.mainSchema, post.embed) || - is(AppBskyEmbedRecordWithMedia.mainSchema, post.embed) - ) { - let embeddedRecordUriStr: string - - if (is(AppBskyEmbedRecord.mainSchema, post.embed.record)) { - embeddedRecordUriStr = post.embed.record.record.uri - } else { - embeddedRecordUriStr = post.embed.record.uri - } - - let urlToAppend: string - const traqMessageId = await getTraqMessageIdByAtProtoUri( - embeddedRecordUriStr, - ) - - if (traqMessageId) { - // This message is already posted to traQ, so we can append its URL to the text - urlToAppend = `${config.traqBaseUrl}/messages/${traqMessageId}` - } else { - // This message is not posted to traQ, so we should append the URL to the original post - const embeddedRecordUri = parseResourceUri(embeddedRecordUriStr) - - if (embeddedRecordUri.ok) { - urlToAppend = - `https://bsky.app/profile/${embeddedRecordUri.value.repo}/post/${embeddedRecordUri.value.rkey}` - } else { - throw new Error("Invalid embedded record URI", { - cause: embeddedRecordUri.error, - }) - } - } - - text = text ? `${text}\n${urlToAppend}` : urlToAppend - } - - return text -} diff --git a/lib/config.ts b/lib/config.ts index c5cfeb0..3b201b3 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -16,6 +16,18 @@ if (!traqClientSecret) { throw new Error("TRAQ_CLIENT_SECRET is not set") } +const blueskyAccountIdentifier = Deno.env.get("BLUESKY_ACCOUNT_IDENTIFIER") + +if (!blueskyAccountIdentifier) { + throw new Error("BLUESKY_ACCOUNT_IDENTIFIER is not set") +} + +const blueskyAppPassword = Deno.env.get("BLUESKY_APP_PASSWORD") + +if (!blueskyAppPassword) { + throw new Error("BLUESKY_APP_PASSWORD is not set") +} + export const config = { qonstellationJwtSecret, dbHost: Deno.env.get("DB_HOST") ?? "localhost", @@ -26,4 +38,6 @@ export const config = { traqBaseUrl: Deno.env.get("TRAQ_BASE_URL") ?? "http://localhost:3000", traqClientId, traqClientSecret, + blueskyAccountIdentifier, + blueskyAppPassword, } as const diff --git a/lib/image.ts b/lib/image.ts index 2edf58f..d5a8b17 100644 --- a/lib/image.ts +++ b/lib/image.ts @@ -1,14 +1,9 @@ +/// import type { AppBskyEmbedImages } from "@atcute/bluesky" -import { Client, simpleFetchHandler } from "@atcute/client" import { type Did } from "@atcute/lexicons" import { isLegacyBlob } from "@atcute/lexicons/interfaces" import { postFile } from "../traq/index.ts" - -const client = new Client({ - handler: simpleFetchHandler({ - service: "https://bsky.social", - }), -}) +import { client } from "./blueskyClient.ts" interface UploadImageParams { accessToken: string @@ -43,12 +38,6 @@ export const uploadImages = async ( ) } - if (!(downloadRes instanceof Blob)) { - throw new Error( - `Unexpected response type when downloading image: ${imageMeta.image.ref.$link}`, - ) - } - const { data: uploadedFile } = await postFile({ headers: { Authorization: `Bearer ${accessToken}`, diff --git a/lib/thread.ts b/lib/thread.ts new file mode 100644 index 0000000..be36ead --- /dev/null +++ b/lib/thread.ts @@ -0,0 +1,44 @@ +import { AppBskyFeedDefs, type AppBskyFeedPost } from "@atcute/bluesky" +import { ok } from "@atcute/client" +import { type Did, is } from "@atcute/lexicons" +import { client } from "./blueskyClient.ts" + +const MAX_PARENT_HEIGHT = 1000 + +interface IsSelfThreadParams { + post: AppBskyFeedPost.Main + authorDid: Did +} + +export const isSelfThread = async ( + { post, authorDid }: IsSelfThreadParams, +): Promise => { + if (!post.reply) { + return true + } + + const { thread } = await ok(client.get("app.bsky.feed.getPostThread", { + params: { + uri: post.reply.parent.uri, + depth: 0, + parentHeight: MAX_PARENT_HEIGHT, + }, + })) + + let current = thread + + while (is(AppBskyFeedDefs.threadViewPostSchema, current)) { + if (current.post.author.did !== authorDid) { + return false + } + + if (!current.parent) { + return true + } + + current = current.parent + } + + // NotFoundPost or BlockedPost — can't confirm all parents are self + return false +} diff --git a/service/jestream.ts b/service/jestream.ts index a9ceb16..0168aea 100644 --- a/service/jestream.ts +++ b/service/jestream.ts @@ -7,9 +7,9 @@ import { import { JetstreamSubscription } from "@atcute/jetstream" import { type Did, is } from "@atcute/lexicons" import { buildAtProtoUri } from "../lib/atProto.ts" -import { buildMessageContent } from "../lib/buildMessageContent.ts" import { config } from "../lib/config.ts" import { uploadImages } from "../lib/image.ts" +import { isSelfThread } from "../lib/thread.ts" import { getTraqMessageIdByAtProtoUri, savePostMetadata, @@ -18,6 +18,7 @@ import { saveJetstreamCursor } from "../repository/systemState.ts" import { getUserAccessToken, getUserSettingByDid } from "../repository/user.ts" import { client } from "../traq/client.gen.ts" import { postMessage } from "../traq/index.ts" +import { MessageBuilder } from "./messageBuilder.ts" client.setConfig({ baseUrl: `${config.traqBaseUrl}/api/v3`, @@ -90,14 +91,16 @@ export class JetstreamService { userDid: event.did, recordKey: event.commit.rkey, }) - const isReply = !!event.commit.record.reply?.parent if ( is(AppBskyEmbedVideo.mainSchema, event.commit.record.embed) || - isReply + !(await isSelfThread({ + post: event.commit.record, + authorDid: event.did, + })) ) { console.warn( - `Skipping post ${atProtoUri} because it has video or is a reply.`, + `Skipping post ${atProtoUri} because it has video or is not a self thread`, ) this.cursor = event.time_us @@ -145,6 +148,10 @@ export class JetstreamService { ) } + const messageBuilder = new MessageBuilder({ + traqAccessToken: accessToken, + targetChannelId: userSetting.targetChannelId, + }) const { data, error } = await postMessage({ headers: { Authorization: `Bearer ${accessToken}`, @@ -153,7 +160,7 @@ export class JetstreamService { channelId: userSetting.targetChannelId, }, body: { - content: await buildMessageContent({ + content: await messageBuilder.build({ imageIds, post: event.commit.record, }), diff --git a/service/messageBuilder.ts b/service/messageBuilder.ts new file mode 100644 index 0000000..423adbe --- /dev/null +++ b/service/messageBuilder.ts @@ -0,0 +1,160 @@ +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + type AppBskyFeedPost, +} from "@atcute/bluesky" +import { is, parseResourceUri } from "@atcute/lexicons" +import { config } from "../lib/config.ts" +import { getTraqMessageIdByAtProtoUri } from "../repository/post.ts" +import { getMessages } from "../traq/sdk.gen.ts" + +interface MessageBuilderConstructorParams { + targetChannelId: string + traqAccessToken: string +} + +interface BuildMessageParams { + post: AppBskyFeedPost.Main + imageIds?: string[] +} + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +export class MessageBuilder { + private readonly targetChannelId: string + private readonly traqAccessToken: string + + constructor( + { targetChannelId, traqAccessToken }: MessageBuilderConstructorParams, + ) { + this.targetChannelId = targetChannelId + this.traqAccessToken = traqAccessToken + } + + async build({ post, imageIds }: BuildMessageParams) { + let text = post.text + + if (post.facets?.length) { + let textBytes = encoder.encode(post.text) + // Sort facets in reverse order to avoid affecting the byte offsets of subsequent facets + const facets = post.facets.sort((a, b) => + b.index.byteStart - a.index.byteStart + ) + + for (const facet of facets) { + const linkFeature = facet.features.find((f) => + f.$type === "app.bsky.richtext.facet#link" + ) + + if (!linkFeature) { + continue + } + + const uriBytes = encoder.encode(linkFeature.uri) + const newBytes = new Uint8Array( + facet.index.byteStart + uriBytes.length + + (textBytes.length - facet.index.byteEnd), + ) + + newBytes.set(textBytes.subarray(0, facet.index.byteStart), 0) + newBytes.set(uriBytes, facet.index.byteStart) + newBytes.set( + textBytes.subarray(facet.index.byteEnd), + facet.index.byteStart + uriBytes.length, + ) + + textBytes = newBytes + } + + text = decoder.decode(textBytes) + } + + if (imageIds?.length) { + const imageLinks = imageIds.map((id) => + `${config.traqBaseUrl}/files/${id}` + ) + .join("\n") + + text = text ? `${text}\n${imageLinks}` : imageLinks + } + + if (post.reply) { + let urlToAppend: string | undefined + const traqMessageId = await getTraqMessageIdByAtProtoUri( + post.reply.parent.uri, + ) + + if (traqMessageId) { + const latestMessageInChannel = await getMessages({ + headers: { + Authorization: `Bearer ${this.traqAccessToken}`, + }, + path: { + channelId: this.targetChannelId, + }, + query: { + limit: 1, + }, + }) + const shouldAppendUrl = + latestMessageInChannel.data?.at(0)?.id !== traqMessageId + + if (shouldAppendUrl) { + urlToAppend = getTraqMessageUrl(traqMessageId) + } + } else { + // This message is not posted to traQ, so we should append the URL to the original post + urlToAppend = getBlueskyPostUrl(post.reply.parent.uri) + } + + if (urlToAppend) { + text = text ? `${text}\n${urlToAppend}` : urlToAppend + } + } + + if ( + is(AppBskyEmbedRecord.mainSchema, post.embed) || + is(AppBskyEmbedRecordWithMedia.mainSchema, post.embed) + ) { + let embeddedRecordUriStr: string + + if (is(AppBskyEmbedRecord.mainSchema, post.embed.record)) { + embeddedRecordUriStr = post.embed.record.record.uri + } else { + embeddedRecordUriStr = post.embed.record.uri + } + + let urlToAppend: string + const traqMessageId = await getTraqMessageIdByAtProtoUri( + embeddedRecordUriStr, + ) + + if (traqMessageId) { + // This message is already posted to traQ, so we can append its URL to the text + urlToAppend = getTraqMessageUrl(traqMessageId) + } else { + // This message is not posted to traQ, so we should append the URL to the original post + urlToAppend = getBlueskyPostUrl(embeddedRecordUriStr) + } + + text = text ? `${text}\n${urlToAppend}` : urlToAppend + } + + return text + } +} + +const getTraqMessageUrl = (messageId: string) => { + return `${config.traqBaseUrl}/messages/${messageId}` +} + +const getBlueskyPostUrl = (uri: string) => { + const parsed = parseResourceUri(uri) + + if (!parsed.ok) { + throw new Error("Invalid post URI", { cause: parsed.error }) + } + + return `https://bsky.app/profile/${parsed.value.repo}/post/${parsed.value.rkey}` +} -- 2.51.2