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