Merge pull request #231 from usbharu/feature/custom-emoji

Feature/custom emoji
This commit is contained in:
usbharu 2024-01-08 18:43:24 +09:00 committed by GitHub
commit c1e1a4d963
53 changed files with 1108 additions and 214 deletions

View File

@ -142,6 +142,15 @@ repositories {
password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN") password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN")
} }
} }
maven {
name = "GitHubPackages2"
url = uri("https://maven.pkg.github.com/multim-dev/emoji-kt")
credentials {
username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME")
password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN")
}
}
} }
kotlin { kotlin {
@ -205,6 +214,8 @@ dependencies {
implementation("org.bytedeco:javacv-platform:1.5.9") implementation("org.bytedeco:javacv-platform:1.5.9")
implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-core")
implementation("dev.usbharu:emoji-kt:2.0.0")
implementation("io.ktor:ktor-client-logging-jvm:$ktor_version") implementation("io.ktor:ktor-client-logging-jvm:$ktor_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")

View File

@ -112,18 +112,17 @@ class InboxCommonTest {
@BeforeAll @BeforeAll
@JvmStatic @JvmStatic
fun beforeAll(@Autowired flyway: Flyway) { fun beforeAll() {
server = MockServer.feature("classpath:federation/InboxxCommonMockServerTest.feature").http(0).build() server = MockServer.feature("classpath:federation/InboxxCommonMockServerTest.feature").http(0).build()
_remotePort = server.port.toString() _remotePort = server.port.toString()
flyway.clean()
flyway.migrate()
} }
@AfterAll @AfterAll
@JvmStatic @JvmStatic
fun afterAll() { fun afterAll(@Autowired flyway: Flyway) {
server.stop() server.stop()
flyway.clean()
flyway.migrate()
} }
} }
} }

View File

@ -1,7 +1,15 @@
package mastodon.status package mastodon.status
import dev.usbharu.hideout.SpringApplication import dev.usbharu.hideout.SpringApplication
import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji
import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji
import dev.usbharu.hideout.core.infrastructure.exposedrepository.CustomEmojis
import dev.usbharu.hideout.core.infrastructure.exposedrepository.Reactions
import dev.usbharu.hideout.core.infrastructure.exposedrepository.toReaction
import org.assertj.core.api.Assertions.assertThat
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -13,19 +21,24 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.context.support.WithAnonymousUser import org.springframework.security.test.context.support.WithAnonymousUser
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
import org.springframework.test.context.jdbc.Sql import org.springframework.test.context.jdbc.Sql
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.put
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.context.WebApplicationContext import org.springframework.web.context.WebApplicationContext
import java.time.Instant
@SpringBootTest(classes = [SpringApplication::class]) @SpringBootTest(classes = [SpringApplication::class])
@AutoConfigureMockMvc @AutoConfigureMockMvc
@Transactional @Transactional
@Sql("/sql/test-user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) @Sql("/sql/test-user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
@Sql("/sql/test-post.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
@Sql("/sql/test-custom-emoji.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
class StatusTest { class StatusTest {
@Autowired @Autowired
@ -124,7 +137,6 @@ class StatusTest {
} }
@Test @Test
@Sql("/sql/test-post.sql")
fun in_reply_to_idを指定したら返信として処理される() { fun in_reply_to_idを指定したら返信として処理される() {
mockMvc mockMvc
.post("/api/v1/statuses") { .post("/api/v1/statuses") {
@ -145,6 +157,64 @@ class StatusTest {
.andExpect { jsonPath("\$.in_reply_to_id") { value("1") } } .andExpect { jsonPath("\$.in_reply_to_id") { value("1") } }
} }
@Test
fun ユニコード絵文字をリアクションできる() {
mockMvc
.put("/api/v1/statuses/1/emoji_reactions/😭") {
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
}
.andDo { print() }
.asyncDispatch()
.andExpect { status { isOk() } }
val reaction = Reactions.select { Reactions.postId eq 1 and (Reactions.actorId eq 1) }.single().toReaction()
assertThat(reaction.emoji).isEqualTo(UnicodeEmoji("😭"))
assertThat(reaction.postId).isEqualTo(1)
assertThat(reaction.actorId).isEqualTo(1)
}
@Test
fun 存在しない絵文字はフォールバックされる() {
mockMvc
.put("/api/v1/statuses/1/emoji_reactions/hoge") {
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
}
.andDo { print() }
.asyncDispatch()
.andExpect { status { isOk() } }
val reaction = Reactions.select { Reactions.postId eq 1 and (Reactions.actorId eq 1) }.single().toReaction()
assertThat(reaction.emoji).isEqualTo(UnicodeEmoji(""))
assertThat(reaction.postId).isEqualTo(1)
assertThat(reaction.actorId).isEqualTo(1)
}
@Test
fun カスタム絵文字をリアクションできる() {
mockMvc
.put("/api/v1/statuses/1/emoji_reactions/kotlin") {
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
}
.andDo { print() }
.asyncDispatch()
.andExpect { status { isOk() } }
val reaction =
Reactions.leftJoin(CustomEmojis).select { Reactions.postId eq 1 and (Reactions.actorId eq 1) }.single()
.toReaction()
assertThat(reaction.emoji).isEqualTo(
CustomEmoji(
1,
"kotlin",
"example.com",
null,
"https://example.com/emojis/kotlin",
null,
Instant.ofEpochMilli(1704700290036)
)
)
}
companion object { companion object {
@JvmStatic @JvmStatic
@AfterAll @AfterAll

View File

@ -0,0 +1,3 @@
insert into emojis(id, name, domain, instance_id, url, category, created_at)
VALUES (1, 'kotlin', 'example.com', null, 'https://example.com/emojis/kotlin', null,
TIMESTAMP '2024-01-08 07:51:30.036Z');

View File

@ -1,6 +1,8 @@
package dev.usbharu.hideout.activitypub.domain.model package dev.usbharu.hideout.activitypub.domain.model
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import dev.usbharu.hideout.activitypub.domain.model.objects.Object import dev.usbharu.hideout.activitypub.domain.model.objects.Object
import dev.usbharu.hideout.activitypub.domain.model.objects.ObjectDeserializer
open class Note open class Note
@Suppress("LongParameterList") @Suppress("LongParameterList")
@ -14,7 +16,9 @@ constructor(
val cc: List<String> = emptyList(), val cc: List<String> = emptyList(),
val sensitive: Boolean = false, val sensitive: Boolean = false,
val inReplyTo: String? = null, val inReplyTo: String? = null,
val attachment: List<Document> = emptyList() val attachment: List<Document> = emptyList(),
@JsonDeserialize(contentUsing = ObjectDeserializer::class)
val tag: List<Object> = emptyList()
) : Object( ) : Object(
type = add(type, "Note") type = add(type, "Note")
), ),
@ -36,6 +40,7 @@ constructor(
if (sensitive != other.sensitive) return false if (sensitive != other.sensitive) return false
if (inReplyTo != other.inReplyTo) return false if (inReplyTo != other.inReplyTo) return false
if (attachment != other.attachment) return false if (attachment != other.attachment) return false
if (tag != other.tag) return false
return true return true
} }
@ -51,21 +56,23 @@ constructor(
result = 31 * result + sensitive.hashCode() result = 31 * result + sensitive.hashCode()
result = 31 * result + (inReplyTo?.hashCode() ?: 0) result = 31 * result + (inReplyTo?.hashCode() ?: 0)
result = 31 * result + attachment.hashCode() result = 31 * result + attachment.hashCode()
result = 31 * result + tag.hashCode()
return result return result
} }
override fun toString(): String { override fun toString(): String {
return "Note(" + return "Note(" +
"id='$id', " + "id='$id', " +
"attributedTo='$attributedTo', " + "attributedTo='$attributedTo', " +
"content='$content', " + "content='$content', " +
"published='$published', " + "published='$published', " +
"to=$to, " + "to=$to, " +
"cc=$cc, " + "cc=$cc, " +
"sensitive=$sensitive, " + "sensitive=$sensitive, " +
"inReplyTo=$inReplyTo, " + "inReplyTo=$inReplyTo, " +
"attachment=$attachment" + "attachment=$attachment, " +
")" + "tag=$tag" +
" ${super.toString()}" ")" +
" ${super.toString()}"
} }
} }

View File

@ -9,7 +9,7 @@ import dev.usbharu.hideout.activitypub.service.common.ExtendedActivityVocabulary
class ObjectDeserializer : JsonDeserializer<Object>() { class ObjectDeserializer : JsonDeserializer<Object>() {
@Suppress("LongMethod", "CyclomaticComplexMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Object { override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Object? {
requireNotNull(p) requireNotNull(p)
val treeNode: JsonNode = requireNotNull(p.codec?.readTree(p)) val treeNode: JsonNode = requireNotNull(p.codec?.readTree(p))
if (treeNode.isValueNode) { if (treeNode.isValueNode) {
@ -24,70 +24,71 @@ class ObjectDeserializer : JsonDeserializer<Object>() {
ExtendedActivityVocabulary.values().firstOrNull { it.name.equals(jsonNode.asText(), true) } ExtendedActivityVocabulary.values().firstOrNull { it.name.equals(jsonNode.asText(), true) }
} }
} else if (type.isValueNode) { } else if (type.isValueNode) {
ExtendedActivityVocabulary.values().first { it.name.equals(type.asText(), true) } ExtendedActivityVocabulary.values().firstOrNull { it.name.equals(type.asText(), true) }
} else { } else {
TODO() null
} }
return when (activityType) { return when (activityType) {
ExtendedActivityVocabulary.Follow -> p.codec.treeToValue(treeNode, Follow::class.java) ExtendedActivityVocabulary.Follow -> p.codec.treeToValue(treeNode, Follow::class.java)
ExtendedActivityVocabulary.Note -> p.codec.treeToValue(treeNode, Note::class.java) ExtendedActivityVocabulary.Note -> p.codec.treeToValue(treeNode, Note::class.java)
ExtendedActivityVocabulary.Object -> p.codec.treeToValue(treeNode, Object::class.java) ExtendedActivityVocabulary.Object -> p.codec.treeToValue(treeNode, Object::class.java)
ExtendedActivityVocabulary.Link -> TODO() ExtendedActivityVocabulary.Link -> null
ExtendedActivityVocabulary.Activity -> TODO() ExtendedActivityVocabulary.Activity -> null
ExtendedActivityVocabulary.IntransitiveActivity -> TODO() ExtendedActivityVocabulary.IntransitiveActivity -> null
ExtendedActivityVocabulary.Collection -> TODO() ExtendedActivityVocabulary.Collection -> null
ExtendedActivityVocabulary.OrderedCollection -> TODO() ExtendedActivityVocabulary.OrderedCollection -> null
ExtendedActivityVocabulary.CollectionPage -> TODO() ExtendedActivityVocabulary.CollectionPage -> null
ExtendedActivityVocabulary.OrderedCollectionPage -> TODO() ExtendedActivityVocabulary.OrderedCollectionPage -> null
ExtendedActivityVocabulary.Accept -> p.codec.treeToValue(treeNode, Accept::class.java) ExtendedActivityVocabulary.Accept -> p.codec.treeToValue(treeNode, Accept::class.java)
ExtendedActivityVocabulary.Add -> TODO() ExtendedActivityVocabulary.Add -> null
ExtendedActivityVocabulary.Announce -> TODO() ExtendedActivityVocabulary.Announce -> null
ExtendedActivityVocabulary.Arrive -> TODO() ExtendedActivityVocabulary.Arrive -> null
ExtendedActivityVocabulary.Block -> p.codec.treeToValue(treeNode, Block::class.java) ExtendedActivityVocabulary.Block -> p.codec.treeToValue(treeNode, Block::class.java)
ExtendedActivityVocabulary.Create -> p.codec.treeToValue(treeNode, Create::class.java) ExtendedActivityVocabulary.Create -> p.codec.treeToValue(treeNode, Create::class.java)
ExtendedActivityVocabulary.Delete -> p.codec.treeToValue(treeNode, Delete::class.java) ExtendedActivityVocabulary.Delete -> p.codec.treeToValue(treeNode, Delete::class.java)
ExtendedActivityVocabulary.Dislike -> TODO() ExtendedActivityVocabulary.Dislike -> null
ExtendedActivityVocabulary.Flag -> TODO() ExtendedActivityVocabulary.Flag -> null
ExtendedActivityVocabulary.Ignore -> TODO() ExtendedActivityVocabulary.Ignore -> null
ExtendedActivityVocabulary.Invite -> TODO() ExtendedActivityVocabulary.Invite -> null
ExtendedActivityVocabulary.Join -> TODO() ExtendedActivityVocabulary.Join -> null
ExtendedActivityVocabulary.Leave -> TODO() ExtendedActivityVocabulary.Leave -> null
ExtendedActivityVocabulary.Like -> p.codec.treeToValue(treeNode, Like::class.java) ExtendedActivityVocabulary.Like -> p.codec.treeToValue(treeNode, Like::class.java)
ExtendedActivityVocabulary.Listen -> TODO() ExtendedActivityVocabulary.Listen -> null
ExtendedActivityVocabulary.Move -> TODO() ExtendedActivityVocabulary.Move -> null
ExtendedActivityVocabulary.Offer -> TODO() ExtendedActivityVocabulary.Offer -> null
ExtendedActivityVocabulary.Question -> TODO() ExtendedActivityVocabulary.Question -> null
ExtendedActivityVocabulary.Reject -> p.codec.treeToValue(treeNode, Reject::class.java) ExtendedActivityVocabulary.Reject -> p.codec.treeToValue(treeNode, Reject::class.java)
ExtendedActivityVocabulary.Read -> TODO() ExtendedActivityVocabulary.Read -> null
ExtendedActivityVocabulary.Remove -> TODO() ExtendedActivityVocabulary.Remove -> null
ExtendedActivityVocabulary.TentativeReject -> TODO() ExtendedActivityVocabulary.TentativeReject -> null
ExtendedActivityVocabulary.TentativeAccept -> TODO() ExtendedActivityVocabulary.TentativeAccept -> null
ExtendedActivityVocabulary.Travel -> TODO() ExtendedActivityVocabulary.Travel -> null
ExtendedActivityVocabulary.Undo -> p.codec.treeToValue(treeNode, Undo::class.java) ExtendedActivityVocabulary.Undo -> p.codec.treeToValue(treeNode, Undo::class.java)
ExtendedActivityVocabulary.Update -> TODO() ExtendedActivityVocabulary.Update -> null
ExtendedActivityVocabulary.View -> TODO() ExtendedActivityVocabulary.View -> null
ExtendedActivityVocabulary.Application -> TODO() ExtendedActivityVocabulary.Application -> null
ExtendedActivityVocabulary.Group -> TODO() ExtendedActivityVocabulary.Group -> null
ExtendedActivityVocabulary.Organization -> TODO() ExtendedActivityVocabulary.Organization -> null
ExtendedActivityVocabulary.Person -> p.codec.treeToValue(treeNode, Person::class.java) ExtendedActivityVocabulary.Person -> p.codec.treeToValue(treeNode, Person::class.java)
ExtendedActivityVocabulary.Service -> TODO() ExtendedActivityVocabulary.Service -> null
ExtendedActivityVocabulary.Article -> TODO() ExtendedActivityVocabulary.Article -> null
ExtendedActivityVocabulary.Audio -> TODO() ExtendedActivityVocabulary.Audio -> null
ExtendedActivityVocabulary.Document -> p.codec.treeToValue(treeNode, Document::class.java) ExtendedActivityVocabulary.Document -> p.codec.treeToValue(treeNode, Document::class.java)
ExtendedActivityVocabulary.Event -> TODO() ExtendedActivityVocabulary.Event -> null
ExtendedActivityVocabulary.Image -> p.codec.treeToValue(treeNode, Image::class.java) ExtendedActivityVocabulary.Image -> p.codec.treeToValue(treeNode, Image::class.java)
ExtendedActivityVocabulary.Page -> TODO() ExtendedActivityVocabulary.Page -> null
ExtendedActivityVocabulary.Place -> TODO() ExtendedActivityVocabulary.Place -> null
ExtendedActivityVocabulary.Profile -> TODO() ExtendedActivityVocabulary.Profile -> null
ExtendedActivityVocabulary.Relationship -> TODO() ExtendedActivityVocabulary.Relationship -> null
ExtendedActivityVocabulary.Tombstone -> p.codec.treeToValue(treeNode, Tombstone::class.java) ExtendedActivityVocabulary.Tombstone -> p.codec.treeToValue(treeNode, Tombstone::class.java)
ExtendedActivityVocabulary.Video -> TODO() ExtendedActivityVocabulary.Video -> null
ExtendedActivityVocabulary.Mention -> TODO() ExtendedActivityVocabulary.Mention -> null
ExtendedActivityVocabulary.Emoji -> p.codec.treeToValue(treeNode, Emoji::class.java) ExtendedActivityVocabulary.Emoji -> p.codec.treeToValue(treeNode, Emoji::class.java)
null -> null
} }
} else { } else {
TODO() return null
} }
} }
} }

View File

@ -1,13 +1,16 @@
package dev.usbharu.hideout.activitypub.service.activity.like package dev.usbharu.hideout.activitypub.service.activity.like
import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException
import dev.usbharu.hideout.activitypub.domain.model.Emoji
import dev.usbharu.hideout.activitypub.domain.model.Like import dev.usbharu.hideout.activitypub.domain.model.Like
import dev.usbharu.hideout.activitypub.service.common.AbstractActivityPubProcessor import dev.usbharu.hideout.activitypub.service.common.AbstractActivityPubProcessor
import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext
import dev.usbharu.hideout.activitypub.service.common.ActivityType import dev.usbharu.hideout.activitypub.service.common.ActivityType
import dev.usbharu.hideout.activitypub.service.objects.emoji.EmojiService
import dev.usbharu.hideout.activitypub.service.objects.note.APNoteService import dev.usbharu.hideout.activitypub.service.objects.note.APNoteService
import dev.usbharu.hideout.activitypub.service.objects.user.APUserService import dev.usbharu.hideout.activitypub.service.objects.user.APUserService
import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji
import dev.usbharu.hideout.core.service.reaction.ReactionService import dev.usbharu.hideout.core.service.reaction.ReactionService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -16,7 +19,8 @@ class APLikeProcessor(
transaction: Transaction, transaction: Transaction,
private val apUserService: APUserService, private val apUserService: APUserService,
private val apNoteService: APNoteService, private val apNoteService: APNoteService,
private val reactionService: ReactionService private val reactionService: ReactionService,
private val emojiService: EmojiService
) : ) :
AbstractActivityPubProcessor<Like>(transaction) { AbstractActivityPubProcessor<Like>(transaction) {
override suspend fun internalProcess(activity: ActivityPubProcessContext<Like>) { override suspend fun internalProcess(activity: ActivityPubProcessContext<Like>) {
@ -29,9 +33,16 @@ class APLikeProcessor(
try { try {
val post = apNoteService.fetchNoteWithEntity(target).second val post = apNoteService.fetchNoteWithEntity(target).second
val emoji = if (content.startsWith(":")) {
val tag = activity.activity.tag
(tag.firstOrNull { it is Emoji } as? Emoji)?.let { emojiService.fetchEmoji(it).second }
} else {
UnicodeEmoji(content)
}
reactionService.receiveReaction( reactionService.receiveReaction(
content, emoji ?: UnicodeEmoji(""),
actor.substringAfter("://").substringBefore("/"),
personWithEntity.second.id, personWithEntity.second.id,
post.id post.id
) )

View File

@ -83,8 +83,6 @@ class APResourceResolveServiceImpl(
return objects == other.objects return objects == other.objects
} }
override fun hashCode(): Int { override fun hashCode(): Int = objects.hashCode()
return objects.hashCode()
}
} }
} }

View File

@ -5,8 +5,10 @@ import dev.usbharu.hideout.activitypub.domain.exception.FailedProcessException
import dev.usbharu.hideout.activitypub.domain.exception.HttpSignatureUnauthorizedException import dev.usbharu.hideout.activitypub.domain.exception.HttpSignatureUnauthorizedException
import dev.usbharu.hideout.activitypub.domain.model.objects.Object import dev.usbharu.hideout.activitypub.domain.model.objects.Object
import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.core.domain.exception.resource.ResourceAccessException
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.sql.SQLException
abstract class AbstractActivityPubProcessor<T : Object>( abstract class AbstractActivityPubProcessor<T : Object>(
private val transaction: Transaction, private val transaction: Transaction,
@ -21,7 +23,11 @@ abstract class AbstractActivityPubProcessor<T : Object>(
logger.info("START ActivityPub process. {}", this.type()) logger.info("START ActivityPub process. {}", this.type())
try { try {
transaction.transaction { transaction.transaction {
internalProcess(activity) try {
internalProcess(activity)
} catch (e: ResourceAccessException) {
throw SQLException(e)
}
} }
} catch (e: ActivityPubProcessException) { } catch (e: ActivityPubProcessException) {
logger.warn("FAILED ActivityPub process", e) logger.warn("FAILED ActivityPub process", e)

View File

@ -112,7 +112,7 @@ class InboxJobProcessor(
logger.debug("Is verifying success? {}", verify) logger.debug("Is verifying success? {}", verify)
val activityPubProcessor = val activityPubProcessor =
activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as ActivityPubProcessor<Object>? activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as? ActivityPubProcessor<Object>
if (activityPubProcessor == null) { if (activityPubProcessor == null) {
logger.warn("ActivityType {} is not support.", param.type) logger.warn("ActivityType {} is not support.", param.type)

View File

@ -0,0 +1,10 @@
package dev.usbharu.hideout.activitypub.service.objects.emoji
import dev.usbharu.hideout.activitypub.domain.model.Emoji
import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji
interface EmojiService {
suspend fun fetchEmoji(url: String): Pair<Emoji, CustomEmoji>
suspend fun fetchEmoji(emoji: Emoji): Pair<Emoji, CustomEmoji>
suspend fun findByEmojiName(emojiName: String): CustomEmoji?
}

View File

@ -0,0 +1,79 @@
package dev.usbharu.hideout.activitypub.service.objects.emoji
import dev.usbharu.hideout.activitypub.domain.model.Emoji
import dev.usbharu.hideout.activitypub.service.common.APResourceResolveServiceImpl
import dev.usbharu.hideout.activitypub.service.common.resolve
import dev.usbharu.hideout.application.config.ApplicationConfig
import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji
import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiRepository
import dev.usbharu.hideout.core.service.instance.InstanceService
import dev.usbharu.hideout.core.service.media.MediaService
import dev.usbharu.hideout.core.service.media.RemoteMedia
import org.springframework.stereotype.Service
import java.net.URL
import java.time.Instant
@Service
class EmojiServiceImpl(
private val customEmojiRepository: CustomEmojiRepository,
private val instanceService: InstanceService,
private val mediaService: MediaService,
private val apResourceResolveServiceImpl: APResourceResolveServiceImpl,
private val applicationConfig: ApplicationConfig
) : EmojiService {
override suspend fun fetchEmoji(url: String): Pair<Emoji, CustomEmoji> {
val emoji = apResourceResolveServiceImpl.resolve<Emoji>(url, null as Long?)
return fetchEmoji(emoji)
}
override suspend fun fetchEmoji(emoji: Emoji): Pair<Emoji, CustomEmoji> = emoji to save(emoji)
private suspend fun save(emoji: Emoji): CustomEmoji {
val domain = URL(emoji.id).host
val name = emoji.name.trim(':')
val customEmoji = customEmojiRepository.findByNameAndDomain(name, domain)
if (customEmoji != null) {
return customEmoji
}
val instance = instanceService.fetchInstance(emoji.id)
val media = mediaService.uploadRemoteMedia(
RemoteMedia(
emoji.name,
emoji.icon.url,
emoji.icon.mediaType.orEmpty(),
null
)
)
val customEmoji1 = CustomEmoji(
id = customEmojiRepository.generateId(),
name = name,
domain = domain,
instanceId = instance.id,
url = media.url,
category = null,
createdAt = Instant.now()
)
return customEmojiRepository.save(customEmoji1)
}
override suspend fun findByEmojiName(emojiName: String): CustomEmoji? {
val split = emojiName.trim(':').split("@")
return when (split.size) {
1 -> {
customEmojiRepository.findByNameAndDomain(split.first(), applicationConfig.url.host)
}
2 -> {
customEmojiRepository.findByNameAndDomain(split.first(), split[1])
}
else -> throw IllegalArgumentException("Unknown Emoji Format. $emojiName")
}
}
}

View File

@ -1,10 +1,12 @@
package dev.usbharu.hideout.activitypub.service.objects.note package dev.usbharu.hideout.activitypub.service.objects.note
import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException
import dev.usbharu.hideout.activitypub.domain.model.Emoji
import dev.usbharu.hideout.activitypub.domain.model.Note import dev.usbharu.hideout.activitypub.domain.model.Note
import dev.usbharu.hideout.activitypub.query.NoteQueryService import dev.usbharu.hideout.activitypub.query.NoteQueryService
import dev.usbharu.hideout.activitypub.service.common.APResourceResolveService import dev.usbharu.hideout.activitypub.service.common.APResourceResolveService
import dev.usbharu.hideout.activitypub.service.common.resolve import dev.usbharu.hideout.activitypub.service.common.resolve
import dev.usbharu.hideout.activitypub.service.objects.emoji.EmojiService
import dev.usbharu.hideout.activitypub.service.objects.user.APUserService import dev.usbharu.hideout.activitypub.service.objects.user.APUserService
import dev.usbharu.hideout.core.domain.model.post.Post import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.domain.model.post.PostRepository import dev.usbharu.hideout.core.domain.model.post.PostRepository
@ -32,7 +34,8 @@ class APNoteServiceImpl(
private val apResourceResolveService: APResourceResolveService, private val apResourceResolveService: APResourceResolveService,
private val postBuilder: Post.PostBuilder, private val postBuilder: Post.PostBuilder,
private val noteQueryService: NoteQueryService, private val noteQueryService: NoteQueryService,
private val mediaService: MediaService private val mediaService: MediaService,
private val emojiService: EmojiService
) : APNoteService { ) : APNoteService {
@ -101,6 +104,15 @@ class APNoteServiceImpl(
postRepository.findByUrl(it) postRepository.findByUrl(it)
} }
val emojis = note.tag
.filterIsInstance<Emoji>()
.map {
emojiService.fetchEmoji(it).second
}
.map {
it.id
}
val mediaList = note.attachment.map { val mediaList = note.attachment.map {
mediaService.uploadRemoteMedia( mediaService.uploadRemoteMedia(
RemoteMedia( RemoteMedia(
@ -123,7 +135,8 @@ class APNoteServiceImpl(
replyId = reply?.id, replyId = reply?.id,
sensitive = note.sensitive, sensitive = note.sensitive,
apId = note.id, apId = note.id,
mediaIds = mediaList mediaIds = mediaList,
emojiIds = emojis
) )
) )
return note to createRemote return note to createRemote

View File

@ -13,12 +13,6 @@ import java.sql.Connection
@Service @Service
class ExposedTransaction : Transaction { class ExposedTransaction : Transaction {
override suspend fun <T> transaction(block: suspend () -> T): T { override suspend fun <T> transaction(block: suspend () -> T): T {
// return newSuspendedTransaction(MDCContext(), transactionIsolation = java.sql.Connection.TRANSACTION_READ_COMMITTED) {
// warnLongQueriesDuration = 1000
// addLogger(Slf4jSqlDebugLogger)
// block()
// }
return transaction(transactionIsolation = Connection.TRANSACTION_READ_COMMITTED) { return transaction(transactionIsolation = Connection.TRANSACTION_READ_COMMITTED) {
debug = true debug = true
warnLongQueriesDuration = 1000 warnLongQueriesDuration = 1000

View File

@ -26,7 +26,8 @@ data class Actor private constructor(
val followersCount: Int = 0, val followersCount: Int = 0,
val followingCount: Int = 0, val followingCount: Int = 0,
val postsCount: Int = 0, val postsCount: Int = 0,
val lastPostDate: Instant? = null val lastPostDate: Instant? = null,
val emojis: List<Long> = emptyList()
) { ) {
@Component @Component
@ -55,7 +56,8 @@ data class Actor private constructor(
followersCount: Int = 0, followersCount: Int = 0,
followingCount: Int = 0, followingCount: Int = 0,
postsCount: Int = 0, postsCount: Int = 0,
lastPostDate: Instant? = null lastPostDate: Instant? = null,
emojis: List<Long> = emptyList()
): Actor { ): Actor {
if (id == 0L) { if (id == 0L) {
return Actor( return Actor(
@ -78,7 +80,8 @@ data class Actor private constructor(
followersCount = followersCount, followersCount = followersCount,
followingCount = followingCount, followingCount = followingCount,
postsCount = postsCount, postsCount = postsCount,
lastPostDate = lastPostDate lastPostDate = lastPostDate,
emojis = emojis
) )
} }
@ -188,7 +191,8 @@ data class Actor private constructor(
followersCount = followersCount, followersCount = followersCount,
followingCount = followingCount, followingCount = followingCount,
postsCount = postsCount, postsCount = postsCount,
lastPostDate = lastPostDate lastPostDate = lastPostDate,
emojis = emojis
) )
} }
} }
@ -206,7 +210,6 @@ data class Actor private constructor(
fun decrementPostsCount(): Actor = this.copy(postsCount = this.postsCount - 1) fun decrementPostsCount(): Actor = this.copy(postsCount = this.postsCount - 1)
fun withLastPostAt(lastPostDate: Instant): Actor = this.copy(lastPostDate = lastPostDate) fun withLastPostAt(lastPostDate: Instant): Actor = this.copy(lastPostDate = lastPostDate)
override fun toString(): String { override fun toString(): String {
return "Actor(" + return "Actor(" +
"id=$id, " + "id=$id, " +
@ -228,7 +231,8 @@ data class Actor private constructor(
"followersCount=$followersCount, " + "followersCount=$followersCount, " +
"followingCount=$followingCount, " + "followingCount=$followingCount, " +
"postsCount=$postsCount, " + "postsCount=$postsCount, " +
"lastPostDate=$lastPostDate" + "lastPostDate=$lastPostDate, " +
"emojis=$emojis" +
")" ")"
} }
} }

View File

@ -0,0 +1,36 @@
package dev.usbharu.hideout.core.domain.model.emoji
import java.time.Instant
sealed class Emoji {
abstract val domain: String
abstract val name: String
@Suppress("FunctionMinLength")
abstract fun id(): String
override fun toString(): String {
return "Emoji(" +
"domain='$domain', " +
"name='$name'" +
")"
}
}
data class CustomEmoji(
val id: Long,
override val name: String,
override val domain: String,
val instanceId: Long?,
val url: String,
val category: String?,
val createdAt: Instant
) : Emoji() {
override fun id(): String = id.toString()
}
data class UnicodeEmoji(
override val name: String
) : Emoji() {
override val domain: String = "unicode.org"
override fun id(): String = name
}

View File

@ -0,0 +1,9 @@
package dev.usbharu.hideout.core.domain.model.emoji
interface CustomEmojiRepository {
suspend fun generateId(): Long
suspend fun save(customEmoji: CustomEmoji): CustomEmoji
suspend fun findById(id: Long): CustomEmoji?
suspend fun delete(customEmoji: CustomEmoji)
suspend fun findByNameAndDomain(name: String, domain: String): CustomEmoji?
}

View File

@ -12,9 +12,7 @@ class Nodeinfo private constructor() {
return links == other.links return links == other.links
} }
override fun hashCode(): Int { override fun hashCode(): Int = links.hashCode()
return links.hashCode()
}
} }
class Links private constructor() { class Links private constructor() {

View File

@ -17,7 +17,8 @@ data class Post private constructor(
val sensitive: Boolean = false, val sensitive: Boolean = false,
val apId: String = url, val apId: String = url,
val mediaIds: List<Long> = emptyList(), val mediaIds: List<Long> = emptyList(),
val delted: Boolean = false val delted: Boolean = false,
val emojiIds: List<Long> = emptyList()
) { ) {
@Component @Component
@ -35,7 +36,8 @@ data class Post private constructor(
replyId: Long? = null, replyId: Long? = null,
sensitive: Boolean = false, sensitive: Boolean = false,
apId: String = url, apId: String = url,
mediaIds: List<Long> = emptyList() mediaIds: List<Long> = emptyList(),
emojiIds: List<Long> = emptyList()
): Post { ): Post {
require(id >= 0) { "id must be greater than or equal to 0." } require(id >= 0) { "id must be greater than or equal to 0." }
@ -74,7 +76,8 @@ data class Post private constructor(
sensitive = sensitive, sensitive = sensitive,
apId = apId, apId = apId,
mediaIds = mediaIds, mediaIds = mediaIds,
delted = false delted = false,
emojiIds = emojiIds
) )
} }

View File

@ -1,3 +1,5 @@
package dev.usbharu.hideout.core.domain.model.reaction package dev.usbharu.hideout.core.domain.model.reaction
data class Reaction(val id: Long, val emojiId: Long, val postId: Long, val actorId: Long) import dev.usbharu.hideout.core.domain.model.emoji.Emoji
data class Reaction(val id: Long, val emoji: Emoji, val postId: Long, val actorId: Long)

View File

@ -1,17 +1,23 @@
package dev.usbharu.hideout.core.domain.model.reaction package dev.usbharu.hideout.core.domain.model.reaction
import dev.usbharu.hideout.core.domain.model.emoji.Emoji
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@Repository @Repository
@Suppress("FunctionMaxLength") @Suppress("FunctionMaxLength", "TooManyFunction")
interface ReactionRepository { interface ReactionRepository {
suspend fun generateId(): Long suspend fun generateId(): Long
suspend fun save(reaction: Reaction): Reaction suspend fun save(reaction: Reaction): Reaction
suspend fun delete(reaction: Reaction): Reaction suspend fun delete(reaction: Reaction): Reaction
suspend fun deleteByPostId(postId: Long): Int suspend fun deleteByPostId(postId: Long): Int
suspend fun deleteByActorId(actorId: Long): Int suspend fun deleteByActorId(actorId: Long): Int
suspend fun deleteByPostIdAndActorId(postId: Long, actorId: Long)
suspend fun deleteByPostIdAndActorIdAndEmoji(postId: Long, actorId: Long, emoji: Emoji)
suspend fun findByPostId(postId: Long): List<Reaction> suspend fun findByPostId(postId: Long): List<Reaction>
suspend fun findByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Reaction? suspend fun findByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Reaction?
suspend fun existByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Boolean suspend fun existByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Boolean
suspend fun existByPostIdAndActorIdAndUnicodeEmoji(postId: Long, actorId: Long, unicodeEmoji: String): Boolean
suspend fun existByPostIdAndActorIdAndEmoji(postId: Long, actorId: Long, emoji: Emoji): Boolean
suspend fun existByPostIdAndActor(postId: Long, actorId: Long): Boolean
suspend fun findByPostIdAndActorId(postId: Long, actorId: Long): List<Reaction> suspend fun findByPostIdAndActorId(postId: Long, actorId: Long): List<Reaction>
} }

View File

@ -11,6 +11,9 @@ import org.springframework.stereotype.Service
@Service @Service
class RelationshipRepositoryImpl : RelationshipRepository, AbstractRepository() { class RelationshipRepositoryImpl : RelationshipRepository, AbstractRepository() {
override val logger: Logger
get() = Companion.logger
override suspend fun save(relationship: Relationship): Relationship = query { override suspend fun save(relationship: Relationship): Relationship = query {
val singleOrNull = Relationships.select { val singleOrNull = Relationships.select {
(Relationships.actorId eq relationship.actorId).and( (Relationships.actorId eq relationship.actorId).and(
@ -94,9 +97,6 @@ class RelationshipRepositoryImpl : RelationshipRepository, AbstractRepository()
return@query query.map { it.toRelationships() } return@query query.map { it.toRelationships() }
} }
override val logger: Logger
get() = Companion.logger
companion object { companion object {
private val logger = LoggerFactory.getLogger(RelationshipRepositoryImpl::class.java) private val logger = LoggerFactory.getLogger(RelationshipRepositoryImpl::class.java)
} }

View File

@ -21,5 +21,6 @@ data class Timeline(
val sensitive: Boolean, val sensitive: Boolean,
val isLocal: Boolean, val isLocal: Boolean,
val isPureRepost: Boolean = false, val isPureRepost: Boolean = false,
val mediaIds: List<Long> = emptyList() val mediaIds: List<Long> = emptyList(),
val emojiIds: List<Long> = emptyList()
) )

View File

@ -4,6 +4,7 @@ import dev.usbharu.hideout.application.infrastructure.exposed.QueryMapper
import dev.usbharu.hideout.application.infrastructure.exposed.ResultRowMapper import dev.usbharu.hideout.application.infrastructure.exposed.ResultRowMapper
import dev.usbharu.hideout.core.domain.model.post.Post import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.infrastructure.exposedrepository.Posts import dev.usbharu.hideout.core.infrastructure.exposedrepository.Posts
import dev.usbharu.hideout.core.infrastructure.exposedrepository.PostsEmojis
import dev.usbharu.hideout.core.infrastructure.exposedrepository.PostsMedia import dev.usbharu.hideout.core.infrastructure.exposedrepository.PostsMedia
import org.jetbrains.exposed.sql.Query import org.jetbrains.exposed.sql.Query
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@ -15,7 +16,10 @@ class PostQueryMapper(private val postResultRowMapper: ResultRowMapper<Post>) :
.map { it.value } .map { it.value }
.map { .map {
it.first().let(postResultRowMapper::map) it.first().let(postResultRowMapper::map)
.copy(mediaIds = it.mapNotNull { resultRow -> resultRow.getOrNull(PostsMedia.mediaId) }) .copy(
mediaIds = it.mapNotNull { resultRow -> resultRow.getOrNull(PostsMedia.mediaId) },
emojiIds = it.mapNotNull { resultRow -> resultRow.getOrNull(PostsEmojis.emojiId) }
)
} }
} }
} }

View File

@ -31,6 +31,7 @@ class UserResultRowMapper(private val actorBuilder: Actor.UserBuilder) : ResultR
followersCount = resultRow[Actors.followersCount], followersCount = resultRow[Actors.followersCount],
postsCount = resultRow[Actors.postsCount], postsCount = resultRow[Actors.postsCount],
lastPostDate = resultRow[Actors.lastPostAt], lastPostDate = resultRow[Actors.lastPostAt],
emojis = resultRow[Actors.emojis].split(",").filter { it.isNotEmpty() }.map { it.toLong() }
) )
} }
} }

View File

@ -1,6 +1,7 @@
package dev.usbharu.hideout.core.infrastructure.exposedrepository package dev.usbharu.hideout.core.infrastructure.exposedrepository
import dev.usbharu.hideout.core.domain.exception.SpringDataAccessExceptionSQLExceptionTranslator import dev.usbharu.hideout.core.domain.exception.SpringDataAccessExceptionSQLExceptionTranslator
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.slf4j.Logger import org.slf4j.Logger
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator
@ -37,6 +38,7 @@ ${Throwable().stackTrace.joinToString("\n")}
if (traceQueryException) { if (traceQueryException) {
logger.trace("FAILED EXECUTE SQL", e) logger.trace("FAILED EXECUTE SQL", e)
} }
TransactionManager.currentOrNull()?.rollback()
if (e.cause !is SQLException) { if (e.cause !is SQLException) {
throw e throw e
} }

View File

@ -45,6 +45,7 @@ class ActorRepositoryImpl(
it[followingCount] = actor.followingCount it[followingCount] = actor.followingCount
it[postsCount] = actor.postsCount it[postsCount] = actor.postsCount
it[lastPostAt] = actor.lastPostDate it[lastPostAt] = actor.lastPostDate
it[emojis] = actor.emojis.joinToString(",")
} }
} else { } else {
Actors.update({ Actors.id eq actor.id }) { Actors.update({ Actors.id eq actor.id }) {
@ -67,6 +68,7 @@ class ActorRepositoryImpl(
it[followingCount] = actor.followingCount it[followingCount] = actor.followingCount
it[postsCount] = actor.postsCount it[postsCount] = actor.postsCount
it[lastPostAt] = actor.lastPostDate it[lastPostAt] = actor.lastPostDate
it[emojis] = actor.emojis.joinToString(",")
} }
} }
return@query actor return@query actor
@ -152,7 +154,7 @@ object Actors : Table("actors") {
val followersCount = integer("followers_count") val followersCount = integer("followers_count")
val postsCount = integer("posts_count") val postsCount = integer("posts_count")
val lastPostAt = timestamp("last_post_at").nullable() val lastPostAt = timestamp("last_post_at").nullable()
val emojis = varchar("emojis", 3000)
override val primaryKey: PrimaryKey = PrimaryKey(id) override val primaryKey: PrimaryKey = PrimaryKey(id)
init { init {

View File

@ -0,0 +1,103 @@
package dev.usbharu.hideout.core.infrastructure.exposedrepository
import dev.usbharu.hideout.application.service.id.IdGenerateService
import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji
import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiRepository
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.javatime.CurrentTimestamp
import org.jetbrains.exposed.sql.javatime.timestamp
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Repository
@Repository
class CustomEmojiRepositoryImpl(private val idGenerateService: IdGenerateService) : CustomEmojiRepository,
AbstractRepository() {
override val logger: Logger
get() = Companion.logger
override suspend fun generateId(): Long = idGenerateService.generateId()
override suspend fun save(customEmoji: CustomEmoji): CustomEmoji = query {
val singleOrNull = CustomEmojis.select { CustomEmojis.id eq customEmoji.id }.forUpdate().singleOrNull()
if (singleOrNull == null) {
CustomEmojis.insert {
it[CustomEmojis.id] = customEmoji.id
it[CustomEmojis.name] = customEmoji.name
it[CustomEmojis.domain] = customEmoji.domain
it[CustomEmojis.instanceId] = customEmoji.instanceId
it[CustomEmojis.url] = customEmoji.url
it[CustomEmojis.category] = customEmoji.category
it[CustomEmojis.createdAt] = customEmoji.createdAt
}
} else {
CustomEmojis.update({ CustomEmojis.id eq customEmoji.id }) {
it[CustomEmojis.name] = customEmoji.name
it[CustomEmojis.domain] = customEmoji.domain
it[CustomEmojis.instanceId] = customEmoji.instanceId
it[CustomEmojis.url] = customEmoji.url
it[CustomEmojis.category] = customEmoji.category
it[CustomEmojis.createdAt] = customEmoji.createdAt
}
}
return@query customEmoji
}
override suspend fun findById(id: Long): CustomEmoji? = query {
return@query CustomEmojis.select { CustomEmojis.id eq id }.singleOrNull()?.toCustomEmoji()
}
override suspend fun delete(customEmoji: CustomEmoji): Unit = query {
CustomEmojis.deleteWhere { CustomEmojis.id eq customEmoji.id }
}
override suspend fun findByNameAndDomain(name: String, domain: String): CustomEmoji? = query {
return@query CustomEmojis
.select { CustomEmojis.name eq name and (CustomEmojis.domain eq domain) }
.singleOrNull()
?.toCustomEmoji()
}
companion object {
private val logger = LoggerFactory.getLogger(CustomEmojiRepositoryImpl::class.java)
}
}
fun ResultRow.toCustomEmoji(): CustomEmoji = CustomEmoji(
id = this[CustomEmojis.id],
name = this[CustomEmojis.name],
domain = this[CustomEmojis.domain],
instanceId = this[CustomEmojis.instanceId],
url = this[CustomEmojis.url],
category = this[CustomEmojis.category],
createdAt = this[CustomEmojis.createdAt]
)
fun ResultRow.toCustomEmojiOrNull(): CustomEmoji? {
return CustomEmoji(
id = this.getOrNull(CustomEmojis.id) ?: return null,
name = this.getOrNull(CustomEmojis.name) ?: return null,
domain = this.getOrNull(CustomEmojis.domain) ?: return null,
instanceId = this[CustomEmojis.instanceId],
url = this.getOrNull(CustomEmojis.url) ?: return null,
category = this[CustomEmojis.category],
createdAt = this.getOrNull(CustomEmojis.createdAt) ?: return null
)
}
object CustomEmojis : Table("emojis") {
val id = long("id")
val name = varchar("name", 1000)
val domain = varchar("domain", 1000)
val instanceId = long("instance_id").references(Instance.id).nullable()
val url = varchar("url", 255).uniqueIndex()
val category = varchar("category", 255).nullable()
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp())
override val primaryKey: PrimaryKey = PrimaryKey(id)
init {
uniqueIndex(name, instanceId)
}
}

View File

@ -37,6 +37,7 @@ class ExposedTimelineRepository(private val idGenerateService: IdGenerateService
it[isLocal] = timeline.isLocal it[isLocal] = timeline.isLocal
it[isPureRepost] = timeline.isPureRepost it[isPureRepost] = timeline.isPureRepost
it[mediaIds] = timeline.mediaIds.joinToString(",") it[mediaIds] = timeline.mediaIds.joinToString(",")
it[emojiIds] = timeline.emojiIds.joinToString(",")
} }
} else { } else {
Timelines.update({ Timelines.id eq timeline.id }) { Timelines.update({ Timelines.id eq timeline.id }) {
@ -52,6 +53,7 @@ class ExposedTimelineRepository(private val idGenerateService: IdGenerateService
it[isLocal] = timeline.isLocal it[isLocal] = timeline.isLocal
it[isPureRepost] = timeline.isPureRepost it[isPureRepost] = timeline.isPureRepost
it[mediaIds] = timeline.mediaIds.joinToString(",") it[mediaIds] = timeline.mediaIds.joinToString(",")
it[emojiIds] = timeline.emojiIds.joinToString(",")
} }
} }
return@query timeline return@query timeline
@ -72,6 +74,7 @@ class ExposedTimelineRepository(private val idGenerateService: IdGenerateService
this[Timelines.isLocal] = it.isLocal this[Timelines.isLocal] = it.isLocal
this[Timelines.isPureRepost] = it.isPureRepost this[Timelines.isPureRepost] = it.isPureRepost
this[Timelines.mediaIds] = it.mediaIds.joinToString(",") this[Timelines.mediaIds] = it.mediaIds.joinToString(",")
this[Timelines.emojiIds] = it.emojiIds.joinToString(",")
} }
return@query timelines return@query timelines
} }
@ -104,7 +107,8 @@ fun ResultRow.toTimeline(): Timeline {
sensitive = this[Timelines.sensitive], sensitive = this[Timelines.sensitive],
isLocal = this[Timelines.isLocal], isLocal = this[Timelines.isLocal],
isPureRepost = this[Timelines.isPureRepost], isPureRepost = this[Timelines.isPureRepost],
mediaIds = this[Timelines.mediaIds].split(",").mapNotNull { it.toLongOrNull() } mediaIds = this[Timelines.mediaIds].split(",").mapNotNull { it.toLongOrNull() },
emojiIds = this[Timelines.emojiIds].split(",").mapNotNull { it.toLongOrNull() }
) )
} }
@ -122,6 +126,7 @@ object Timelines : Table("timelines") {
val isLocal = bool("is_local") val isLocal = bool("is_local")
val isPureRepost = bool("is_pure_repost") val isPureRepost = bool("is_pure_repost")
val mediaIds = varchar("media_ids", 255) val mediaIds = varchar("media_ids", 255)
val emojiIds = varchar("emoji_ids", 255)
override val primaryKey: PrimaryKey = PrimaryKey(id) override val primaryKey: PrimaryKey = PrimaryKey(id)

View File

@ -41,14 +41,25 @@ class PostRepositoryImpl(
this[PostsMedia.postId] = post.id this[PostsMedia.postId] = post.id
this[PostsMedia.mediaId] = it this[PostsMedia.mediaId] = it
} }
PostsEmojis.batchInsert(post.emojiIds) {
this[PostsEmojis.postId] = post.id
this[PostsEmojis.emojiId] = it
}
} else { } else {
PostsMedia.deleteWhere { PostsMedia.deleteWhere {
postId eq post.id postId eq post.id
} }
PostsEmojis.deleteWhere {
postId eq post.id
}
PostsMedia.batchInsert(post.mediaIds) { PostsMedia.batchInsert(post.mediaIds) {
this[PostsMedia.postId] = post.id this[PostsMedia.postId] = post.id
this[PostsMedia.mediaId] = it this[PostsMedia.mediaId] = it
} }
PostsEmojis.batchInsert(post.emojiIds) {
this[PostsEmojis.postId] = post.id
this[PostsEmojis.emojiId] = it
}
Posts.update({ Posts.id eq post.id }) { Posts.update({ Posts.id eq post.id }) {
it[actorId] = post.actorId it[actorId] = post.actorId
it[overview] = post.overview it[overview] = post.overview
@ -67,21 +78,27 @@ class PostRepositoryImpl(
} }
override suspend fun findById(id: Long): Post? = query { override suspend fun findById(id: Long): Post? = query {
return@query Posts.leftJoin(PostsMedia) return@query Posts
.leftJoin(PostsMedia)
.leftJoin(PostsEmojis)
.select { Posts.id eq id } .select { Posts.id eq id }
.let(postQueryMapper::map) .let(postQueryMapper::map)
.singleOrNull() .singleOrNull()
} }
override suspend fun findByUrl(url: String): Post? = query { override suspend fun findByUrl(url: String): Post? = query {
return@query Posts.leftJoin(PostsMedia) return@query Posts
.leftJoin(PostsMedia)
.leftJoin(PostsEmojis)
.select { Posts.url eq url } .select { Posts.url eq url }
.let(postQueryMapper::map) .let(postQueryMapper::map)
.singleOrNull() .singleOrNull()
} }
override suspend fun findByApId(apId: String): Post? = query { override suspend fun findByApId(apId: String): Post? = query {
return@query Posts.leftJoin(PostsMedia) return@query Posts
.leftJoin(PostsMedia)
.leftJoin(PostsEmojis)
.select { Posts.apId eq apId } .select { Posts.apId eq apId }
.let(postQueryMapper::map) .let(postQueryMapper::map)
.singleOrNull() .singleOrNull()
@ -92,7 +109,10 @@ class PostRepositoryImpl(
} }
override suspend fun findByActorId(actorId: Long): List<Post> = query { override suspend fun findByActorId(actorId: Long): List<Post> = query {
return@query Posts.select { Posts.actorId eq actorId }.let(postQueryMapper::map) return@query Posts
.leftJoin(PostsMedia)
.leftJoin(PostsEmojis)
.select { Posts.actorId eq actorId }.let(postQueryMapper::map)
} }
override suspend fun delete(id: Long): Unit = query { override suspend fun delete(id: Long): Unit = query {
@ -125,3 +145,9 @@ object PostsMedia : Table("posts_media") {
val mediaId = long("media_id").references(Media.id, ReferenceOption.CASCADE, ReferenceOption.CASCADE) val mediaId = long("media_id").references(Media.id, ReferenceOption.CASCADE, ReferenceOption.CASCADE)
override val primaryKey = PrimaryKey(postId, mediaId) override val primaryKey = PrimaryKey(postId, mediaId)
} }
object PostsEmojis : Table("posts_emojis") {
val postId = long("post_id").references(Posts.id)
val emojiId = long("emoji_id").references(CustomEmojis.id)
override val primaryKey: PrimaryKey = PrimaryKey(postId, emojiId)
}

View File

@ -1,6 +1,9 @@
package dev.usbharu.hideout.core.infrastructure.exposedrepository package dev.usbharu.hideout.core.infrastructure.exposedrepository
import dev.usbharu.hideout.application.service.id.IdGenerateService import dev.usbharu.hideout.application.service.id.IdGenerateService
import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji
import dev.usbharu.hideout.core.domain.model.emoji.Emoji
import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji
import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.domain.model.reaction.Reaction
import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository
import org.jetbrains.exposed.dao.id.LongIdTable import org.jetbrains.exposed.dao.id.LongIdTable
@ -23,13 +26,25 @@ class ReactionRepositoryImpl(
if (Reactions.select { Reactions.id eq reaction.id }.forUpdate().empty()) { if (Reactions.select { Reactions.id eq reaction.id }.forUpdate().empty()) {
Reactions.insert { Reactions.insert {
it[id] = reaction.id it[id] = reaction.id
it[emojiId] = reaction.emojiId if (reaction.emoji is CustomEmoji) {
it[customEmojiId] = reaction.emoji.id
it[unicodeEmoji] = null
} else {
it[customEmojiId] = null
it[unicodeEmoji] = reaction.emoji.name
}
it[postId] = reaction.postId it[postId] = reaction.postId
it[actorId] = reaction.actorId it[actorId] = reaction.actorId
} }
} else { } else {
Reactions.update({ Reactions.id eq reaction.id }) { Reactions.update({ Reactions.id eq reaction.id }) {
it[emojiId] = reaction.emojiId if (reaction.emoji is CustomEmoji) {
it[customEmojiId] = reaction.emoji.id
it[unicodeEmoji] = null
} else {
it[customEmojiId] = null
it[unicodeEmoji] = reaction.emoji.name
}
it[postId] = reaction.postId it[postId] = reaction.postId
it[actorId] = reaction.actorId it[actorId] = reaction.actorId
} }
@ -38,9 +53,16 @@ class ReactionRepositoryImpl(
} }
override suspend fun delete(reaction: Reaction): Reaction = query { override suspend fun delete(reaction: Reaction): Reaction = query {
Reactions.deleteWhere { if (reaction.emoji is CustomEmoji) {
id.eq(reaction.id).and(postId.eq(reaction.postId)).and(actorId.eq(reaction.actorId)) Reactions.deleteWhere {
.and(emojiId.eq(reaction.emojiId)) id.eq(reaction.id).and(postId.eq(reaction.postId)).and(actorId.eq(reaction.actorId))
.and(customEmojiId.eq(reaction.emoji.id))
}
} else {
Reactions.deleteWhere {
id.eq(reaction.id).and(postId.eq(reaction.postId)).and(actorId.eq(reaction.actorId))
.and(unicodeEmoji.eq(reaction.emoji.name))
}
} }
return@query reaction return@query reaction
} }
@ -57,15 +79,37 @@ class ReactionRepositoryImpl(
} }
} }
override suspend fun deleteByPostIdAndActorId(postId: Long, actorId: Long): Unit = query {
Reactions.deleteWhere {
Reactions.postId eq postId and (Reactions.actorId eq actorId)
}
}
override suspend fun deleteByPostIdAndActorIdAndEmoji(postId: Long, actorId: Long, emoji: Emoji): Unit = query {
if (emoji is CustomEmoji) {
Reactions.deleteWhere {
Reactions.postId.eq(postId)
.and(Reactions.actorId.eq(actorId))
.and(Reactions.customEmojiId.eq(emoji.id))
}
} else {
Reactions.deleteWhere {
Reactions.postId.eq(postId)
.and(Reactions.actorId.eq(actorId))
.and(Reactions.unicodeEmoji.eq(emoji.name))
}
}
}
override suspend fun findByPostId(postId: Long): List<Reaction> = query { override suspend fun findByPostId(postId: Long): List<Reaction> = query {
return@query Reactions.select { Reactions.postId eq postId }.map { it.toReaction() } return@query Reactions.leftJoin(CustomEmojis).select { Reactions.postId eq postId }.map { it.toReaction() }
} }
override suspend fun findByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Reaction? = override suspend fun findByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Reaction? =
query { query {
return@query Reactions.select { return@query Reactions.leftJoin(CustomEmojis).select {
Reactions.postId eq postId and (Reactions.actorId eq actorId).and( Reactions.postId eq postId and (Reactions.actorId eq actorId).and(
Reactions.emojiId.eq( Reactions.customEmojiId.eq(
emojiId emojiId
) )
) )
@ -78,12 +122,49 @@ class ReactionRepositoryImpl(
Reactions.postId Reactions.postId
.eq(postId) .eq(postId)
.and(Reactions.actorId.eq(actorId)) .and(Reactions.actorId.eq(actorId))
.and(Reactions.emojiId.eq(emojiId)) .and(Reactions.customEmojiId.eq(emojiId))
}.empty().not() }.empty().not()
} }
override suspend fun existByPostIdAndActorIdAndUnicodeEmoji(
postId: Long,
actorId: Long,
unicodeEmoji: String
): Boolean = query {
return@query Reactions.select {
Reactions.postId
.eq(postId)
.and(Reactions.actorId.eq(actorId))
.and(Reactions.unicodeEmoji.eq(unicodeEmoji))
}.empty().not()
}
override suspend fun existByPostIdAndActorIdAndEmoji(postId: Long, actorId: Long, emoji: Emoji): Boolean = query {
val query = Reactions.select {
Reactions.postId
.eq(postId)
.and(Reactions.actorId.eq(actorId))
}
if (emoji is UnicodeEmoji) {
query.andWhere { Reactions.unicodeEmoji eq emoji.name }
} else {
emoji as CustomEmoji
query.andWhere { Reactions.customEmojiId eq emoji.id }
}
return@query query.empty().not()
}
override suspend fun existByPostIdAndActor(postId: Long, actorId: Long): Boolean = query {
Reactions.select {
Reactions.postId.eq(postId).and(Reactions.actorId.eq(actorId))
}.empty().not()
}
override suspend fun findByPostIdAndActorId(postId: Long, actorId: Long): List<Reaction> = query { override suspend fun findByPostIdAndActorId(postId: Long, actorId: Long): List<Reaction> = query {
return@query Reactions.select { Reactions.postId eq postId and (Reactions.actorId eq actorId) } return@query Reactions.leftJoin(CustomEmojis)
.select { Reactions.postId eq postId and (Reactions.actorId eq actorId) }
.map { it.toReaction() } .map { it.toReaction() }
} }
@ -93,22 +174,39 @@ class ReactionRepositoryImpl(
} }
fun ResultRow.toReaction(): Reaction { fun ResultRow.toReaction(): Reaction {
val emoji = if (this[Reactions.customEmojiId] != null) {
CustomEmoji(
id = this[Reactions.customEmojiId]!!,
name = this[CustomEmojis.name],
domain = this[CustomEmojis.domain],
instanceId = this[CustomEmojis.instanceId],
url = this[CustomEmojis.url],
category = this[CustomEmojis.category],
createdAt = this[CustomEmojis.createdAt]
)
} else if (this[Reactions.unicodeEmoji] != null) {
UnicodeEmoji(this[Reactions.unicodeEmoji]!!)
} else {
throw IllegalStateException("customEmojiId and unicodeEmoji is null.")
}
return Reaction( return Reaction(
this[Reactions.id].value, this[Reactions.id].value,
this[Reactions.emojiId], emoji,
this[Reactions.postId], this[Reactions.postId],
this[Reactions.actorId] this[Reactions.actorId]
) )
} }
object Reactions : LongIdTable("reactions") { object Reactions : LongIdTable("reactions") {
val emojiId: Column<Long> = long("emoji_id") val customEmojiId = long("custom_emoji_id").references(CustomEmojis.id).nullable()
val unicodeEmoji = varchar("unicode_emoji", 255).nullable()
val postId: Column<Long> = val postId: Column<Long> =
long("post_id").references(Posts.id, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) long("post_id").references(Posts.id, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val actorId: Column<Long> = val actorId: Column<Long> =
long("actor_id").references(Actors.id, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) long("actor_id").references(Actors.id, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
init { init {
uniqueIndex(emojiId, postId, actorId) uniqueIndex(customEmojiId, postId, actorId)
} }
} }

View File

@ -26,11 +26,6 @@ class UserDetailsImpl(
accountNonLocked: Boolean, accountNonLocked: Boolean,
authorities: MutableCollection<out GrantedAuthority>? authorities: MutableCollection<out GrantedAuthority>?
) : User(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities) { ) : User(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities) {
companion object {
@Serial
private const val serialVersionUID: Long = -899168205656607781L
}
override fun toString(): String { override fun toString(): String {
return "UserDetailsImpl(" + return "UserDetailsImpl(" +
"id=$id" + "id=$id" +
@ -53,6 +48,11 @@ class UserDetailsImpl(
result = 31 * result + id.hashCode() result = 31 * result + id.hashCode()
return result return result
} }
companion object {
@Serial
private const val serialVersionUID: Long = -899168205656607781L
}
} }
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY)

View File

@ -10,9 +10,7 @@ sealed class SavedMedia(val success: Boolean) {
return success == other.success return success == other.success
} }
override fun hashCode(): Int { override fun hashCode(): Int = success.hashCode()
return success.hashCode()
}
} }
class SuccessSavedMedia( class SuccessSavedMedia(

View File

@ -1,11 +1,12 @@
package dev.usbharu.hideout.core.service.reaction package dev.usbharu.hideout.core.service.reaction
import dev.usbharu.hideout.core.domain.model.emoji.Emoji
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
interface ReactionService { interface ReactionService {
suspend fun receiveReaction(name: String, domain: String, actorId: Long, postId: Long) suspend fun receiveReaction(emoji: Emoji, actorId: Long, postId: Long)
suspend fun receiveRemoveReaction(actorId: Long, postId: Long) suspend fun receiveRemoveReaction(actorId: Long, postId: Long)
suspend fun sendReaction(name: String, actorId: Long, postId: Long) suspend fun sendReaction(emoji: Emoji, actorId: Long, postId: Long)
suspend fun removeReaction(actorId: Long, postId: Long) suspend fun removeReaction(actorId: Long, postId: Long)
} }

View File

@ -1,9 +1,10 @@
package dev.usbharu.hideout.core.service.reaction package dev.usbharu.hideout.core.service.reaction
import dev.usbharu.hideout.activitypub.service.activity.like.APReactionService import dev.usbharu.hideout.activitypub.service.activity.like.APReactionService
import dev.usbharu.hideout.core.domain.exception.resource.DuplicateException
import dev.usbharu.hideout.core.domain.model.emoji.Emoji
import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.domain.model.reaction.Reaction
import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository
import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -13,14 +14,17 @@ class ReactionServiceImpl(
private val reactionRepository: ReactionRepository, private val reactionRepository: ReactionRepository,
private val apReactionService: APReactionService private val apReactionService: APReactionService
) : ReactionService { ) : ReactionService {
override suspend fun receiveReaction(name: String, domain: String, actorId: Long, postId: Long) { override suspend fun receiveReaction(
if (reactionRepository.existByPostIdAndActorIdAndEmojiId(postId, actorId, 0).not()) { emoji: Emoji,
try { actorId: Long,
reactionRepository.save( postId: Long
Reaction(reactionRepository.generateId(), 0, postId, actorId) ) {
) if (reactionRepository.existByPostIdAndActor(postId, actorId)) {
} catch (_: ExposedSQLException) { reactionRepository.deleteByPostIdAndActorId(postId, actorId)
} }
try {
reactionRepository.save(Reaction(reactionRepository.generateId(), emoji, postId, actorId))
} catch (_: DuplicateException) {
} }
} }
@ -33,7 +37,7 @@ class ReactionServiceImpl(
reactionRepository.delete(reaction) reactionRepository.delete(reaction)
} }
override suspend fun sendReaction(name: String, actorId: Long, postId: Long) { override suspend fun sendReaction(emoji: Emoji, actorId: Long, postId: Long) {
val findByPostIdAndUserIdAndEmojiId = val findByPostIdAndUserIdAndEmojiId =
reactionRepository.findByPostIdAndActorIdAndEmojiId(postId, actorId, 0) reactionRepository.findByPostIdAndActorIdAndEmojiId(postId, actorId, 0)
@ -42,7 +46,7 @@ class ReactionServiceImpl(
reactionRepository.delete(findByPostIdAndUserIdAndEmojiId) reactionRepository.delete(findByPostIdAndUserIdAndEmojiId)
} }
val reaction = Reaction(reactionRepository.generateId(), 0, postId, actorId) val reaction = Reaction(reactionRepository.generateId(), emoji, postId, actorId)
reactionRepository.save(reaction) reactionRepository.save(reaction)
apReactionService.reaction(reaction) apReactionService.reaction(reaction)
} }

View File

@ -45,7 +45,8 @@ class ExposedGenerateTimelineService(private val statusQueryService: StatusQuery
it[Timelines.postId], it[Timelines.postId],
it[Timelines.replyId], it[Timelines.replyId],
it[Timelines.repostId], it[Timelines.repostId],
it[Timelines.mediaIds].split(",").mapNotNull { s -> s.toLongOrNull() } it[Timelines.mediaIds].split(",").mapNotNull { s -> s.toLongOrNull() },
it[Timelines.emojiIds].split(",").mapNotNull { s -> s.toLongOrNull() }
) )
} }

View File

@ -57,7 +57,8 @@ class MongoGenerateTimelineService(
it.postId, it.postId,
it.replyId, it.replyId,
it.repostId, it.repostId,
it.mediaIds it.mediaIds,
it.emojiIds
) )
} }
) )

View File

@ -37,7 +37,8 @@ class TimelineService(
sensitive = post.sensitive, sensitive = post.sensitive,
isLocal = isLocal, isLocal = isLocal,
isPureRepost = post.repostId == null || (post.text.isBlank() && post.overview.isNullOrBlank()), isPureRepost = post.repostId == null || (post.text.isBlank() && post.overview.isNullOrBlank()),
mediaIds = post.mediaIds mediaIds = post.mediaIds,
emojiIds = post.emojiIds
) )
}.toMutableList() }.toMutableList()
if (post.visibility == Visibility.PUBLIC) { if (post.visibility == Visibility.PUBLIC) {
@ -55,7 +56,8 @@ class TimelineService(
sensitive = post.sensitive, sensitive = post.sensitive,
isLocal = isLocal, isLocal = isLocal,
isPureRepost = post.repostId == null || (post.text.isBlank() && post.overview.isNullOrBlank()), isPureRepost = post.repostId == null || (post.text.isBlank() && post.overview.isNullOrBlank()),
mediaIds = post.mediaIds mediaIds = post.mediaIds,
emojiIds = post.emojiIds
) )
) )
} }

View File

@ -1,5 +1,6 @@
package dev.usbharu.hideout.mastodon.infrastructure.exposedquery package dev.usbharu.hideout.mastodon.infrastructure.exposedquery
import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji
import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments
import dev.usbharu.hideout.core.infrastructure.exposedrepository.* import dev.usbharu.hideout.core.infrastructure.exposedrepository.*
import dev.usbharu.hideout.domain.mastodon.model.generated.Account import dev.usbharu.hideout.domain.mastodon.model.generated.Account
@ -12,6 +13,7 @@ import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.Instant import java.time.Instant
import dev.usbharu.hideout.domain.mastodon.model.generated.CustomEmoji as MastodonEmoji
@Suppress("IncompleteDestructuring") @Suppress("IncompleteDestructuring")
@Repository @Repository
@ -23,6 +25,10 @@ class StatusQueryServiceImpl : StatusQueryService {
postIdSet.addAll(statusQueries.flatMap { listOfNotNull(it.postId, it.replyId, it.repostId) }) postIdSet.addAll(statusQueries.flatMap { listOfNotNull(it.postId, it.replyId, it.repostId) })
val mediaIdSet = mutableSetOf<Long>() val mediaIdSet = mutableSetOf<Long>()
mediaIdSet.addAll(statusQueries.flatMap { it.mediaIds }) mediaIdSet.addAll(statusQueries.flatMap { it.mediaIds })
val emojiIdSet = mutableSetOf<Long>()
emojiIdSet.addAll(statusQueries.flatMap { it.emojiIds })
val postMap = Posts val postMap = Posts
.leftJoin(Actors) .leftJoin(Actors)
.select { Posts.id inList postIdSet } .select { Posts.id inList postIdSet }
@ -32,12 +38,16 @@ class StatusQueryServiceImpl : StatusQueryService {
it[Media.id] to it.toMedia().toMediaAttachments() it[Media.id] to it.toMedia().toMediaAttachments()
} }
val emojiMap = CustomEmojis.select { CustomEmojis.id inList emojiIdSet }.associate {
it[CustomEmojis.id] to it.toCustomEmoji().toMastodonEmoji()
}
return statusQueries.mapNotNull { statusQuery -> return statusQueries.mapNotNull { statusQuery ->
postMap[statusQuery.postId]?.copy( postMap[statusQuery.postId]?.copy(
inReplyToId = statusQuery.replyId?.toString(), inReplyToId = statusQuery.replyId?.toString(),
inReplyToAccountId = postMap[statusQuery.replyId]?.account?.id, inReplyToAccountId = postMap[statusQuery.replyId]?.account?.id,
reblog = postMap[statusQuery.repostId], reblog = postMap[statusQuery.repostId],
mediaAttachments = statusQuery.mediaIds.mapNotNull { mediaMap[it] } mediaAttachments = statusQuery.mediaIds.mapNotNull { mediaMap[it] },
emojis = statusQuery.emojiIds.mapNotNull { emojiMap[it] }
) )
} }
} }
@ -98,6 +108,25 @@ class StatusQueryServiceImpl : StatusQueryService {
return resolveReplyAndRepost(pairs) return resolveReplyAndRepost(pairs)
} }
override suspend fun findByPostId(id: Long): Status {
val map = Posts
.leftJoin(PostsMedia)
.leftJoin(Actors)
.leftJoin(Media)
.select { Posts.id eq id }
.groupBy { it[Posts.id] }
.map { it.value }
.map {
toStatus(it.first()).copy(
mediaAttachments = it.mapNotNull { resultRow ->
resultRow.toMediaOrNull()?.toMediaAttachments()
},
emojis = it.mapNotNull { resultRow -> resultRow.toCustomEmojiOrNull()?.toMastodonEmoji() }
) to it.first()[Posts.repostId]
}
return resolveReplyAndRepost(map).single()
}
private fun resolveReplyAndRepost(pairs: List<Pair<Status, Long?>>): List<Status> { private fun resolveReplyAndRepost(pairs: List<Pair<Status, Long?>>): List<Status> {
val statuses = pairs.map { it.first } val statuses = pairs.map { it.first }
return pairs return pairs
@ -120,6 +149,8 @@ class StatusQueryServiceImpl : StatusQueryService {
private suspend fun findByPostIdsWithMedia(ids: List<Long>): List<Status> { private suspend fun findByPostIdsWithMedia(ids: List<Long>): List<Status> {
val pairs = Posts val pairs = Posts
.leftJoin(PostsMedia) .leftJoin(PostsMedia)
.leftJoin(PostsEmojis)
.leftJoin(CustomEmojis)
.leftJoin(Actors) .leftJoin(Actors)
.leftJoin(Media) .leftJoin(Media)
.select { Posts.id inList ids } .select { Posts.id inList ids }
@ -129,13 +160,22 @@ class StatusQueryServiceImpl : StatusQueryService {
toStatus(it.first()).copy( toStatus(it.first()).copy(
mediaAttachments = it.mapNotNull { resultRow -> mediaAttachments = it.mapNotNull { resultRow ->
resultRow.toMediaOrNull()?.toMediaAttachments() resultRow.toMediaOrNull()?.toMediaAttachments()
} },
emojis = it.mapNotNull { resultRow -> resultRow.toCustomEmojiOrNull()?.toMastodonEmoji() }
) to it.first()[Posts.repostId] ) to it.first()[Posts.repostId]
} }
return resolveReplyAndRepost(pairs) return resolveReplyAndRepost(pairs)
} }
} }
private fun CustomEmoji.toMastodonEmoji(): MastodonEmoji = MastodonEmoji(
shortcode = this.name,
url = this.url,
staticUrl = this.url,
visibleInPicker = true,
category = this.category.orEmpty()
)
private fun toStatus(it: ResultRow) = Status( private fun toStatus(it: ResultRow) = Status(
id = it[Posts.id].toString(), id = it[Posts.id].toString(),
uri = it[Posts.apId], uri = it[Posts.apId],

View File

@ -24,4 +24,20 @@ class MastodonStatusesApiContoller(private val statusesApiService: StatusesApiSe
HttpStatus.OK HttpStatus.OK
) )
} }
override suspend fun apiV1StatusesIdEmojiReactionsEmojiDelete(id: String, emoji: String): ResponseEntity<Status> {
val uid =
(SecurityContextHolder.getContext().authentication.principal as Jwt).getClaim<String>("uid").toLong()
return ResponseEntity.ok(statusesApiService.removeEmojiReactions(id.toLong(), uid, emoji))
}
override suspend fun apiV1StatusesIdEmojiReactionsEmojiPut(id: String, emoji: String): ResponseEntity<Status> {
val uid =
(SecurityContextHolder.getContext().authentication.principal as Jwt).getClaim<String>("uid").toLong()
return ResponseEntity.ok(statusesApiService.emojiReactions(id.toLong(), uid, emoji))
}
override suspend fun apiV1StatusesIdGet(id: String): ResponseEntity<Status> = super.apiV1StatusesIdGet(id)
} }

View File

@ -4,5 +4,6 @@ data class StatusQuery(
val postId: Long, val postId: Long,
val replyId: Long?, val replyId: Long?,
val repostId: Long?, val repostId: Long?,
val mediaIds: List<Long> val mediaIds: List<Long>,
val emojiIds: List<Long>
) )

View File

@ -36,4 +36,6 @@ interface StatusQueryService {
tagged: String? = null, tagged: String? = null,
includeFollowers: Boolean = false includeFollowers: Boolean = false
): List<Status> ): List<Status>
suspend fun findByPostId(id: Long): Status
} }

View File

@ -1,17 +1,24 @@
package dev.usbharu.hideout.mastodon.service.status package dev.usbharu.hideout.mastodon.service.status
import dev.usbharu.hideout.activitypub.service.objects.emoji.EmojiService
import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.core.domain.model.actor.ActorRepository import dev.usbharu.hideout.core.domain.model.actor.ActorRepository
import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji
import dev.usbharu.hideout.core.domain.model.media.MediaRepository import dev.usbharu.hideout.core.domain.model.media.MediaRepository
import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments
import dev.usbharu.hideout.core.domain.model.post.PostRepository import dev.usbharu.hideout.core.domain.model.post.PostRepository
import dev.usbharu.hideout.core.domain.model.relationship.RelationshipRepository
import dev.usbharu.hideout.core.service.post.PostCreateDto import dev.usbharu.hideout.core.service.post.PostCreateDto
import dev.usbharu.hideout.core.service.post.PostService import dev.usbharu.hideout.core.service.post.PostService
import dev.usbharu.hideout.core.service.reaction.ReactionService
import dev.usbharu.hideout.domain.mastodon.model.generated.Status import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.domain.mastodon.model.generated.Status.Visibility.*
import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusesRequest import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusesRequest
import dev.usbharu.hideout.mastodon.interfaces.api.status.toPostVisibility import dev.usbharu.hideout.mastodon.interfaces.api.status.toPostVisibility
import dev.usbharu.hideout.mastodon.interfaces.api.status.toStatusVisibility import dev.usbharu.hideout.mastodon.interfaces.api.status.toStatusVisibility
import dev.usbharu.hideout.mastodon.query.StatusQueryService
import dev.usbharu.hideout.mastodon.service.account.AccountService import dev.usbharu.hideout.mastodon.service.account.AccountService
import dev.usbharu.hideout.util.EmojiUtil
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Instant import java.time.Instant
@ -22,16 +29,38 @@ interface StatusesApiService {
statusesRequest: StatusesRequest, statusesRequest: StatusesRequest,
userId: Long userId: Long
): Status ): Status
suspend fun findById(
id: Long,
userId: Long?
): Status?
suspend fun emojiReactions(
postId: Long,
userId: Long,
emojiName: String
): Status?
suspend fun removeEmojiReactions(
postId: Long,
userId: Long,
emojiName: String
): Status?
} }
@Service @Service
@Suppress("LongParameterList")
class StatsesApiServiceImpl( class StatsesApiServiceImpl(
private val postService: PostService, private val postService: PostService,
private val accountService: AccountService, private val accountService: AccountService,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val transaction: Transaction, private val transaction: Transaction,
private val actorRepository: ActorRepository, private val actorRepository: ActorRepository,
private val postRepository: PostRepository private val postRepository: PostRepository,
private val statusQueryService: StatusQueryService,
private val relationshipRepository: RelationshipRepository,
private val reactionService: ReactionService,
private val emojiService: EmojiService
) : ) :
StatusesApiService { StatusesApiService {
override suspend fun postStatus( override suspend fun postStatus(
@ -95,6 +124,61 @@ class StatsesApiServiceImpl(
) )
} }
override suspend fun findById(id: Long, userId: Long?): Status? {
val status = statusQueryService.findByPostId(id)
return status(status, userId)
}
private suspend fun status(
status: Status,
userId: Long?
): Status? {
return when (status.visibility) {
public -> status
unlisted -> status
private -> {
if (userId == null) {
return null
}
val relationship =
relationshipRepository.findByUserIdAndTargetUserId(userId, status.account.id.toLong())
?: return null
if (relationship.following) {
return status
}
return null
}
direct -> null
}
}
override suspend fun emojiReactions(postId: Long, userId: Long, emojiName: String): Status? {
status(statusQueryService.findByPostId(postId), userId) ?: return null
val emoji = try {
if (EmojiUtil.isEmoji(emojiName)) {
UnicodeEmoji(emojiName)
} else {
emojiService.findByEmojiName(emojiName)!!
}
} catch (_: IllegalStateException) {
UnicodeEmoji("")
} catch (_: NullPointerException) {
UnicodeEmoji("")
}
reactionService.sendReaction(emoji, userId, postId)
return statusQueryService.findByPostId(postId)
}
override suspend fun removeEmojiReactions(postId: Long, userId: Long, emojiName: String): Status? {
reactionService.removeReaction(userId, postId)
return status(statusQueryService.findByPostId(postId), userId)
}
companion object { companion object {
private val logger = LoggerFactory.getLogger(StatusesApiService::class.java) private val logger = LoggerFactory.getLogger(StatusesApiService::class.java)
} }

View File

@ -0,0 +1,14 @@
package dev.usbharu.hideout.util
import Emojis
object EmojiUtil {
val emojiMap by lazy {
Emojis.allEmojis
.associate { it.code.replace(" ", "-") to it.char }
.filterValues { it != "" }
}
fun isEmoji(string: String): Boolean = emojiMap.any { it.value == string }
}

View File

@ -19,7 +19,5 @@ class TempFile<T : Path?>(val path: T) : AutoCloseable {
return path == other.path return path == other.path
} }
override fun hashCode(): Int { override fun hashCode(): Int = path?.hashCode() ?: 0
return path?.hashCode() ?: 0
}
} }

View File

@ -1,3 +1,15 @@
create table if not exists emojis
(
id bigint primary key,
"name" varchar(1000) not null,
domain varchar(1000) not null,
instance_id bigint null,
url varchar(255) not null unique,
category varchar(255),
created_at timestamp not null default current_timestamp,
unique ("name", instance_id)
);
create table if not exists instance create table if not exists instance
( (
id bigint primary key, id bigint primary key,
@ -13,6 +25,10 @@ create table if not exists instance
moderation_note varchar(10000) not null, moderation_note varchar(10000) not null,
created_at timestamp not null created_at timestamp not null
); );
alter table emojis
add constraint fk_emojis_instance_id__id foreign key (instance_id) references instance (id) on delete cascade on update cascade;
create table if not exists actors create table if not exists actors
( (
id bigint primary key, id bigint primary key,
@ -34,7 +50,8 @@ create table if not exists actors
following_count int not null, following_count int not null,
followers_count int not null, followers_count int not null,
posts_count int not null, posts_count int not null,
last_post_at timestamp null default null, last_post_at timestamp null default null,
emojis varchar(300) not null default '',
unique ("name", "domain"), unique ("name", "domain"),
constraint fk_actors_instance__id foreign key ("instance") references instance (id) on delete restrict on update restrict constraint fk_actors_instance__id foreign key ("instance") references instance (id) on delete restrict on update restrict
); );
@ -99,24 +116,42 @@ alter table posts_media
add constraint fk_posts_media_post_id__id foreign key (post_id) references posts (id) on delete cascade on update cascade; add constraint fk_posts_media_post_id__id foreign key (post_id) references posts (id) on delete cascade on update cascade;
alter table posts_media alter table posts_media
add constraint fk_posts_media_media_id__id foreign key (media_id) references media (id) on delete cascade on update cascade; add constraint fk_posts_media_media_id__id foreign key (media_id) references media (id) on delete cascade on update cascade;
create table if not exists posts_emojis
(
post_id bigint not null,
emoji_id bigint not null,
constraint pk_postsemoji primary key (post_id, emoji_id)
);
alter table posts_emojis
add constraint fk_posts_emojis_post_id__id foreign key (post_id) references posts (id) on delete cascade on update cascade;
alter table posts_emojis
add constraint fk_posts_emojis_emoji_id__id foreign key (emoji_id) references emojis (id) on delete cascade on update cascade;
create table if not exists reactions create table if not exists reactions
( (
id bigserial primary key, id bigint primary key,
emoji_id bigint not null, unicode_emoji varchar(255) null default null,
post_id bigint not null, custom_emoji_id bigint null default null,
actor_id bigint not null post_id bigint not null,
actor_id bigint not null,
unique (post_id, actor_id)
); );
alter table reactions alter table reactions
add constraint fk_reactions_post_id__id foreign key (post_id) references posts (id) on delete restrict on update restrict; add constraint fk_reactions_post_id__id foreign key (post_id) references posts (id) on delete restrict on update restrict;
alter table reactions alter table reactions
add constraint fk_reactions_actor_id__id foreign key (actor_id) references actors (id) on delete restrict on update restrict; add constraint fk_reactions_actor_id__id foreign key (actor_id) references actors (id) on delete restrict on update restrict;
alter table reactions
add constraint fk_reactions_custom_emoji_id__id foreign key (custom_emoji_id) references emojis (id) on delete cascade on update cascade;
create table if not exists timelines create table if not exists timelines
( (
id bigint primary key, id bigint primary key,
user_id bigint not null, user_id bigint not null,
timeline_id bigint not null, timeline_id bigint not null,
post_id bigint not null, post_id bigint not null,
post_actor_id bigint not null, post_actor_id bigint not null,
created_at bigint not null, created_at bigint not null,
reply_id bigint null, reply_id bigint null,
repost_id bigint null, repost_id bigint null,
@ -124,7 +159,8 @@ create table if not exists timelines
"sensitive" boolean not null, "sensitive" boolean not null,
is_local boolean not null, is_local boolean not null,
is_pure_repost boolean not null, is_pure_repost boolean not null,
media_ids varchar(255) not null media_ids varchar(255) not null,
emoji_ids varchar(255) not null
); );
create table if not exists application_authorization create table if not exists application_authorization

View File

@ -152,6 +152,79 @@ paths:
schema: schema:
$ref: "#/components/schemas/Status" $ref: "#/components/schemas/Status"
/api/v1/statuses/{id}:
get:
tags:
- status
security:
- OAuth2:
- "write:statuses"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Status"
/api/v1/statuses/{id}/emoji_reactions/{emoji}:
put:
tags:
- status
security:
- OAuth2:
- "write:statuses"
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: emoji
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Status"
delete:
tags:
- status
security:
- OAuth2:
- "write:statuses"
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: emoji
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Status"
/api/v1/apps: /api/v1/apps:
post: post:
tags: tags:

View File

@ -3,6 +3,7 @@ package dev.usbharu.hideout
import com.fasterxml.jackson.module.kotlin.isKotlinClass import com.fasterxml.jackson.module.kotlin.isKotlinClass
import com.jparams.verifier.tostring.ToStringVerifier import com.jparams.verifier.tostring.ToStringVerifier
import com.jparams.verifier.tostring.preset.Presets import com.jparams.verifier.tostring.preset.Presets
import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji
import nl.jqno.equalsverifier.EqualsVerifier import nl.jqno.equalsverifier.EqualsVerifier
import nl.jqno.equalsverifier.Warning import nl.jqno.equalsverifier.Warning
import nl.jqno.equalsverifier.internal.reflection.PackageScanner import nl.jqno.equalsverifier.internal.reflection.PackageScanner
@ -95,6 +96,7 @@ class EqualsAndToStringTest {
.filter { .filter {
it.superclass == Any::class.java || it.superclass?.packageName?.startsWith("dev.usbharu") ?: true it.superclass == Any::class.java || it.superclass?.packageName?.startsWith("dev.usbharu") ?: true
} }
.filterNot { it == UnicodeEmoji::class.java }
.map { .map {
dynamicTest(it.name) { dynamicTest(it.name) {

View File

@ -3,6 +3,7 @@ package dev.usbharu.hideout.activitypub.domain.model
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import dev.usbharu.hideout.activitypub.service.objects.note.APNoteServiceImpl.Companion.public import dev.usbharu.hideout.activitypub.service.objects.note.APNoteServiceImpl.Companion.public
import dev.usbharu.hideout.application.config.ActivityPubConfig import dev.usbharu.hideout.application.config.ActivityPubConfig
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -51,11 +52,7 @@ class NoteSerializeTest {
"attachment": [], "attachment": [],
"sensitive": false, "sensitive": false,
"tag": [ "tag": [
{
"type": "Mention",
"href": "https://calckey.jp/users/9bu1xzwjyb",
"name": "@trapezial@calckey.jp"
}
] ]
}""" }"""
@ -77,4 +74,105 @@ class NoteSerializeTest {
) )
assertEquals(note, readValue) assertEquals(note, readValue)
} }
@Test
fun 絵文字付きNoteのデシリアライズができる() {
val json = """{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"sensitive": "as:sensitive",
"Hashtag": "as:Hashtag",
"quoteUrl": "as:quoteUrl",
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji",
"featured": "toot:featured",
"discoverable": "toot:discoverable",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
"misskey": "https://misskey-hub.net/ns#",
"_misskey_content": "misskey:_misskey_content",
"_misskey_quote": "misskey:_misskey_quote",
"_misskey_reaction": "misskey:_misskey_reaction",
"_misskey_votes": "misskey:_misskey_votes",
"_misskey_summary": "misskey:_misskey_summary",
"isCat": "misskey:isCat",
"vcard": "http://www.w3.org/2006/vcard/ns#"
}
],
"id": "https://misskey.usbharu.dev/notes/9nj1omt1rn",
"type": "Note",
"attributedTo": "https://misskey.usbharu.dev/users/97ws8y3rj6",
"content": "<p>:oyasumi:</p>",
"_misskey_content": ":oyasumi:",
"source": {
"content": ":oyasumi:",
"mediaType": "text/x.misskeymarkdown"
},
"published": "2023-12-21T17:32:36.853Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://misskey.usbharu.dev/users/97ws8y3rj6/followers"
],
"inReplyTo": null,
"attachment": [],
"sensitive": false,
"tag": [
{
"id": "https://misskey.usbharu.dev/emojis/oyasumi",
"type": "Emoji",
"name": ":oyasumi:",
"updated": "2023-04-07T08:21:25.246Z",
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://s3misskey.usbharu.dev/misskey-minio/misskey-minio/data/cf8db710-1d70-4076-8a00-dbb28131096e.png"
}
}
]
}"""
val objectMapper = ActivityPubConfig().objectMapper()
val expected = Note(
type = emptyList(),
id = "https://misskey.usbharu.dev/notes/9nj1omt1rn",
attributedTo = "https://misskey.usbharu.dev/users/97ws8y3rj6",
content = "<p>\u200B:oyasumi:\u200B</p>",
published = "2023-12-21T17:32:36.853Z",
to = listOf("https://www.w3.org/ns/activitystreams#Public"),
cc = listOf("https://misskey.usbharu.dev/users/97ws8y3rj6/followers"),
sensitive = false,
inReplyTo = null,
attachment = emptyList(),
tag = listOf(
Emoji(
type = emptyList(),
name = ":oyasumi:",
id = "https://misskey.usbharu.dev/emojis/oyasumi",
updated = "2023-04-07T08:21:25.246Z",
icon = Image(
type = emptyList(),
mediaType = "image/png",
url = "https://s3misskey.usbharu.dev/misskey-minio/misskey-minio/data/cf8db710-1d70-4076-8a00-dbb28131096e.png"
)
)
)
)
expected.context = listOf(
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
)
val note = objectMapper.readValue<Note>(json)
assertThat(note).isEqualTo(expected)
}
} }

View File

@ -3,6 +3,7 @@ package dev.usbharu.hideout.activitypub.service.activity.like
import dev.usbharu.hideout.application.service.id.TwitterSnowflakeIdGenerateService import dev.usbharu.hideout.application.service.id.TwitterSnowflakeIdGenerateService
import dev.usbharu.hideout.core.domain.model.actor.ActorRepository import dev.usbharu.hideout.core.domain.model.actor.ActorRepository
import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji
import dev.usbharu.hideout.core.domain.model.post.PostRepository import dev.usbharu.hideout.core.domain.model.post.PostRepository
import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.domain.model.reaction.Reaction
import dev.usbharu.hideout.core.external.job.DeliverReactionJob import dev.usbharu.hideout.core.external.job.DeliverReactionJob
@ -48,7 +49,7 @@ class APReactionServiceImplTest {
apReactionServiceImpl.reaction( apReactionServiceImpl.reaction(
Reaction( Reaction(
id = TwitterSnowflakeIdGenerateService.generateId(), id = TwitterSnowflakeIdGenerateService.generateId(),
emojiId = 0, emoji = UnicodeEmoji(""),
postId = post.id, postId = post.id,
actorId = user.id actorId = user.id
) )
@ -88,7 +89,7 @@ class APReactionServiceImplTest {
apReactionServiceImpl.removeReaction( apReactionServiceImpl.removeReaction(
Reaction( Reaction(
id = TwitterSnowflakeIdGenerateService.generateId(), id = TwitterSnowflakeIdGenerateService.generateId(),
emojiId = 0, emoji = UnicodeEmoji(""),
postId = post.id, postId = post.id,
actorId = user.id actorId = user.id
) )

View File

@ -73,6 +73,7 @@ class APNoteServiceImplTest {
apResourceResolveService = mock(), apResourceResolveService = mock(),
postBuilder = Post.PostBuilder(CharacterLimit()), postBuilder = Post.PostBuilder(CharacterLimit()),
noteQueryService = noteQueryService, noteQueryService = noteQueryService,
mock(),
mock() mock()
) )
@ -142,7 +143,8 @@ class APNoteServiceImplTest {
apResourceResolveService = apResourceResolveService, apResourceResolveService = apResourceResolveService,
postBuilder = Post.PostBuilder(CharacterLimit()), postBuilder = Post.PostBuilder(CharacterLimit()),
noteQueryService = noteQueryService, noteQueryService = noteQueryService,
mock() mock(),
mock { }
) )
val actual = apNoteServiceImpl.fetchNote(url) val actual = apNoteServiceImpl.fetchNote(url)
@ -190,6 +192,7 @@ class APNoteServiceImplTest {
apResourceResolveService = apResourceResolveService, apResourceResolveService = apResourceResolveService,
postBuilder = Post.PostBuilder(CharacterLimit()), postBuilder = Post.PostBuilder(CharacterLimit()),
noteQueryService = noteQueryService, noteQueryService = noteQueryService,
mock(),
mock() mock()
) )
@ -240,6 +243,7 @@ class APNoteServiceImplTest {
apResourceResolveService = mock(), apResourceResolveService = mock(),
postBuilder = postBuilder, postBuilder = postBuilder,
noteQueryService = noteQueryService, noteQueryService = noteQueryService,
mock(),
mock() mock()
) )
@ -292,6 +296,7 @@ class APNoteServiceImplTest {
apResourceResolveService = mock(), apResourceResolveService = mock(),
postBuilder = postBuilder, postBuilder = postBuilder,
noteQueryService = noteQueryService, noteQueryService = noteQueryService,
mock(),
mock() mock()
) )

View File

@ -3,10 +3,10 @@ package dev.usbharu.hideout.core.service.reaction
import dev.usbharu.hideout.activitypub.service.activity.like.APReactionService import dev.usbharu.hideout.activitypub.service.activity.like.APReactionService
import dev.usbharu.hideout.application.service.id.TwitterSnowflakeIdGenerateService import dev.usbharu.hideout.application.service.id.TwitterSnowflakeIdGenerateService
import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji
import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.domain.model.reaction.Reaction
import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks import org.mockito.InjectMocks
@ -32,59 +32,33 @@ class ReactionServiceImplTest {
val post = PostBuilder.of() val post = PostBuilder.of()
whenever(reactionRepository.existByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( whenever(reactionRepository.existByPostIdAndActor(eq(post.id), eq(post.actorId))).doReturn(
false false
) )
val generateId = TwitterSnowflakeIdGenerateService.generateId() val generateId = TwitterSnowflakeIdGenerateService.generateId()
whenever(reactionRepository.generateId()).doReturn(generateId) whenever(reactionRepository.generateId()).doReturn(generateId)
reactionServiceImpl.receiveReaction("", "example.com", post.actorId, post.id) reactionServiceImpl.receiveReaction(UnicodeEmoji(""), post.actorId, post.id)
verify(reactionRepository, times(1)).save(eq(Reaction(generateId, 0, post.id, post.actorId))) verify(reactionRepository, times(1)).save(eq(Reaction(generateId, UnicodeEmoji(""), post.id, post.actorId)))
} }
@Test
fun `receiveReaction リアクションが既に作成されていることを検知出来ずに例外が発生した場合は何もしない`() = runTest {
val post = PostBuilder.of()
whenever(reactionRepository.existByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn(
false
)
val generateId = TwitterSnowflakeIdGenerateService.generateId()
whenever(
reactionRepository.save(
eq(
Reaction(
id = generateId,
emojiId = 0,
postId = post.id,
actorId = post.actorId
)
)
)
).doAnswer {
throw ExposedSQLException(
null,
emptyList(), mock()
)
}
whenever(reactionRepository.generateId()).doReturn(generateId)
reactionServiceImpl.receiveReaction("", "example.com", post.actorId, post.id)
verify(reactionRepository, times(1)).save(eq(Reaction(generateId, 0, post.id, post.actorId)))
}
@Test @Test
fun `receiveReaction リアクションが既に作成されている場合は何もしない`() = runTest() { fun `receiveReaction リアクションが既に作成されている場合削除して新しく作成`() = runTest() {
val post = PostBuilder.of() val post = PostBuilder.of()
whenever(reactionRepository.existByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( whenever(reactionRepository.existByPostIdAndActor(eq(post.id), eq(post.actorId))).doReturn(
true true
) )
reactionServiceImpl.receiveReaction("", "example.com", post.actorId, post.id) val generateId = TwitterSnowflakeIdGenerateService.generateId()
verify(reactionRepository, never()).save(any()) whenever(reactionRepository.generateId()).doReturn(generateId)
reactionServiceImpl.receiveReaction(UnicodeEmoji(""), post.actorId, post.id)
verify(reactionRepository, times(1)).deleteByPostIdAndActorId(post.id, post.actorId)
verify(reactionRepository, times(1)).save(Reaction(generateId, UnicodeEmoji(""), post.id, post.actorId))
} }
@Test @Test
@ -96,10 +70,10 @@ class ReactionServiceImplTest {
val generateId = TwitterSnowflakeIdGenerateService.generateId() val generateId = TwitterSnowflakeIdGenerateService.generateId()
whenever(reactionRepository.generateId()).doReturn(generateId) whenever(reactionRepository.generateId()).doReturn(generateId)
reactionServiceImpl.sendReaction("", post.actorId, post.id) reactionServiceImpl.sendReaction(UnicodeEmoji(""), post.actorId, post.id)
verify(reactionRepository, times(1)).save(eq(Reaction(generateId, 0, post.id, post.actorId))) verify(reactionRepository, times(1)).save(eq(Reaction(generateId, UnicodeEmoji(""), post.id, post.actorId)))
verify(apReactionService, times(1)).reaction(eq(Reaction(generateId, 0, post.id, post.actorId))) verify(apReactionService, times(1)).reaction(eq(Reaction(generateId, UnicodeEmoji(""), post.id, post.actorId)))
} }
@Test @Test
@ -107,30 +81,30 @@ class ReactionServiceImplTest {
val post = PostBuilder.of() val post = PostBuilder.of()
val id = TwitterSnowflakeIdGenerateService.generateId() val id = TwitterSnowflakeIdGenerateService.generateId()
whenever(reactionRepository.findByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( whenever(reactionRepository.findByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn(
Reaction(id, 0, post.id, post.actorId) Reaction(id, UnicodeEmoji(""), post.id, post.actorId)
) )
val generateId = TwitterSnowflakeIdGenerateService.generateId() val generateId = TwitterSnowflakeIdGenerateService.generateId()
whenever(reactionRepository.generateId()).doReturn(generateId) whenever(reactionRepository.generateId()).doReturn(generateId)
reactionServiceImpl.sendReaction("", post.actorId, post.id) reactionServiceImpl.sendReaction(UnicodeEmoji(""), post.actorId, post.id)
verify(reactionRepository, times(1)).delete(eq(Reaction(id, 0, post.id, post.actorId))) verify(reactionRepository, times(1)).delete(eq(Reaction(id, UnicodeEmoji(""), post.id, post.actorId)))
verify(reactionRepository, times(1)).save(eq(Reaction(generateId, 0, post.id, post.actorId))) verify(reactionRepository, times(1)).save(eq(Reaction(generateId, UnicodeEmoji(""), post.id, post.actorId)))
verify(apReactionService, times(1)).removeReaction(eq(Reaction(id, 0, post.id, post.actorId))) verify(apReactionService, times(1)).removeReaction(eq(Reaction(id, UnicodeEmoji(""), post.id, post.actorId)))
verify(apReactionService, times(1)).reaction(eq(Reaction(generateId, 0, post.id, post.actorId))) verify(apReactionService, times(1)).reaction(eq(Reaction(generateId, UnicodeEmoji(""), post.id, post.actorId)))
} }
@Test @Test
fun `removeReaction リアクションが存在する場合削除して配送`() = runTest { fun `removeReaction リアクションが存在する場合削除して配送`() = runTest {
val post = PostBuilder.of() val post = PostBuilder.of()
whenever(reactionRepository.findByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( whenever(reactionRepository.findByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn(
Reaction(0, 0, post.id, post.actorId) Reaction(0, UnicodeEmoji(""), post.id, post.actorId)
) )
reactionServiceImpl.removeReaction(post.actorId, post.id) reactionServiceImpl.removeReaction(post.actorId, post.id)
verify(reactionRepository, times(1)).delete(eq(Reaction(0, 0, post.id, post.actorId))) verify(reactionRepository, times(1)).delete(eq(Reaction(0, UnicodeEmoji(""), post.id, post.actorId)))
verify(apReactionService, times(1)).removeReaction(eq(Reaction(0, 0, post.id, post.actorId))) verify(apReactionService, times(1)).removeReaction(eq(Reaction(0, UnicodeEmoji(""), post.id, post.actorId)))
} }
} }

View File

@ -0,0 +1,41 @@
package dev.usbharu.hideout.util
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
class EmojiUtilTest {
@Test
fun 絵文字を判定できる() {
val emoji = ""
val actual = EmojiUtil.isEmoji(emoji)
assertThat(actual).isTrue()
}
@Test
fun ただの文字を判定できる() {
val moji = "blobblinkhyper"
val actual = EmojiUtil.isEmoji(moji)
assertThat(actual).isFalse()
}
@ParameterizedTest
@ValueSource(strings = ["", "🌄", "🤗", "", "🧑‍🤝‍🧑", "🖐🏿"])
fun `絵文字判定`(s: String) {
val actual = EmojiUtil.isEmoji(s)
assertThat(actual).isTrue()
}
@ParameterizedTest
@ValueSource(strings = ["", "", ""])
fun `文字判定`(s: String) {
val actual = EmojiUtil.isEmoji(s)
assertThat(actual).isFalse()
}
}