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")
}
}
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 {
@ -205,6 +214,8 @@ dependencies {
implementation("org.bytedeco:javacv-platform:1.5.9")
implementation("org.flywaydb:flyway-core")
implementation("dev.usbharu:emoji-kt:2.0.0")
implementation("io.ktor:ktor-client-logging-jvm:$ktor_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")

View File

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

View File

@ -1,7 +1,15 @@
package mastodon.status
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.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
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.web.servlet.request.SecurityMockMvcRequestPostProcessors
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.test.context.jdbc.Sql
import org.springframework.test.web.servlet.MockMvc
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.MockMvcBuilders
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.context.WebApplicationContext
import java.time.Instant
@SpringBootTest(classes = [SpringApplication::class])
@AutoConfigureMockMvc
@Transactional
@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 {
@Autowired
@ -124,7 +137,6 @@ class StatusTest {
}
@Test
@Sql("/sql/test-post.sql")
fun in_reply_to_idを指定したら返信として処理される() {
mockMvc
.post("/api/v1/statuses") {
@ -145,6 +157,64 @@ class StatusTest {
.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 {
@JvmStatic
@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
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import dev.usbharu.hideout.activitypub.domain.model.objects.Object
import dev.usbharu.hideout.activitypub.domain.model.objects.ObjectDeserializer
open class Note
@Suppress("LongParameterList")
@ -14,7 +16,9 @@ constructor(
val cc: List<String> = emptyList(),
val sensitive: Boolean = false,
val inReplyTo: String? = null,
val attachment: List<Document> = emptyList()
val attachment: List<Document> = emptyList(),
@JsonDeserialize(contentUsing = ObjectDeserializer::class)
val tag: List<Object> = emptyList()
) : Object(
type = add(type, "Note")
),
@ -36,6 +40,7 @@ constructor(
if (sensitive != other.sensitive) return false
if (inReplyTo != other.inReplyTo) return false
if (attachment != other.attachment) return false
if (tag != other.tag) return false
return true
}
@ -51,21 +56,23 @@ constructor(
result = 31 * result + sensitive.hashCode()
result = 31 * result + (inReplyTo?.hashCode() ?: 0)
result = 31 * result + attachment.hashCode()
result = 31 * result + tag.hashCode()
return result
}
override fun toString(): String {
return "Note(" +
"id='$id', " +
"attributedTo='$attributedTo', " +
"content='$content', " +
"published='$published', " +
"to=$to, " +
"cc=$cc, " +
"sensitive=$sensitive, " +
"inReplyTo=$inReplyTo, " +
"attachment=$attachment" +
")" +
" ${super.toString()}"
"id='$id', " +
"attributedTo='$attributedTo', " +
"content='$content', " +
"published='$published', " +
"to=$to, " +
"cc=$cc, " +
"sensitive=$sensitive, " +
"inReplyTo=$inReplyTo, " +
"attachment=$attachment, " +
"tag=$tag" +
")" +
" ${super.toString()}"
}
}

View File

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

View File

@ -1,13 +1,16 @@
package dev.usbharu.hideout.activitypub.service.activity.like
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.service.common.AbstractActivityPubProcessor
import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext
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.user.APUserService
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 org.springframework.stereotype.Service
@ -16,7 +19,8 @@ class APLikeProcessor(
transaction: Transaction,
private val apUserService: APUserService,
private val apNoteService: APNoteService,
private val reactionService: ReactionService
private val reactionService: ReactionService,
private val emojiService: EmojiService
) :
AbstractActivityPubProcessor<Like>(transaction) {
override suspend fun internalProcess(activity: ActivityPubProcessContext<Like>) {
@ -29,9 +33,16 @@ class APLikeProcessor(
try {
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(
content,
actor.substringAfter("://").substringBefore("/"),
emoji ?: UnicodeEmoji(""),
personWithEntity.second.id,
post.id
)

View File

@ -83,8 +83,6 @@ class APResourceResolveServiceImpl(
return objects == other.objects
}
override fun hashCode(): Int {
return objects.hashCode()
}
override fun hashCode(): Int = 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.model.objects.Object
import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.core.domain.exception.resource.ResourceAccessException
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.sql.SQLException
abstract class AbstractActivityPubProcessor<T : Object>(
private val transaction: Transaction,
@ -21,7 +23,11 @@ abstract class AbstractActivityPubProcessor<T : Object>(
logger.info("START ActivityPub process. {}", this.type())
try {
transaction.transaction {
internalProcess(activity)
try {
internalProcess(activity)
} catch (e: ResourceAccessException) {
throw SQLException(e)
}
}
} catch (e: ActivityPubProcessException) {
logger.warn("FAILED ActivityPub process", e)

View File

@ -112,7 +112,7 @@ class InboxJobProcessor(
logger.debug("Is verifying success? {}", verify)
val activityPubProcessor =
activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as ActivityPubProcessor<Object>?
activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as? ActivityPubProcessor<Object>
if (activityPubProcessor == null) {
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
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.query.NoteQueryService
import dev.usbharu.hideout.activitypub.service.common.APResourceResolveService
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.core.domain.model.post.Post
import dev.usbharu.hideout.core.domain.model.post.PostRepository
@ -32,7 +34,8 @@ class APNoteServiceImpl(
private val apResourceResolveService: APResourceResolveService,
private val postBuilder: Post.PostBuilder,
private val noteQueryService: NoteQueryService,
private val mediaService: MediaService
private val mediaService: MediaService,
private val emojiService: EmojiService
) : APNoteService {
@ -101,6 +104,15 @@ class APNoteServiceImpl(
postRepository.findByUrl(it)
}
val emojis = note.tag
.filterIsInstance<Emoji>()
.map {
emojiService.fetchEmoji(it).second
}
.map {
it.id
}
val mediaList = note.attachment.map {
mediaService.uploadRemoteMedia(
RemoteMedia(
@ -123,7 +135,8 @@ class APNoteServiceImpl(
replyId = reply?.id,
sensitive = note.sensitive,
apId = note.id,
mediaIds = mediaList
mediaIds = mediaList,
emojiIds = emojis
)
)
return note to createRemote

View File

@ -13,12 +13,6 @@ import java.sql.Connection
@Service
class ExposedTransaction : Transaction {
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) {
debug = true
warnLongQueriesDuration = 1000

View File

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

View File

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

View File

@ -1,3 +1,5 @@
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
import dev.usbharu.hideout.core.domain.model.emoji.Emoji
import org.springframework.stereotype.Repository
@Repository
@Suppress("FunctionMaxLength")
@Suppress("FunctionMaxLength", "TooManyFunction")
interface ReactionRepository {
suspend fun generateId(): Long
suspend fun save(reaction: Reaction): Reaction
suspend fun delete(reaction: Reaction): Reaction
suspend fun deleteByPostId(postId: 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 findByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Reaction?
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>
}

View File

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

View File

@ -21,5 +21,6 @@ data class Timeline(
val sensitive: Boolean,
val isLocal: Boolean,
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.core.domain.model.post.Post
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 org.jetbrains.exposed.sql.Query
import org.springframework.stereotype.Component
@ -15,7 +16,10 @@ class PostQueryMapper(private val postResultRowMapper: ResultRowMapper<Post>) :
.map { it.value }
.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],
postsCount = resultRow[Actors.postsCount],
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
import dev.usbharu.hideout.core.domain.exception.SpringDataAccessExceptionSQLExceptionTranslator
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.slf4j.Logger
import org.springframework.beans.factory.annotation.Value
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator
@ -37,6 +38,7 @@ ${Throwable().stackTrace.joinToString("\n")}
if (traceQueryException) {
logger.trace("FAILED EXECUTE SQL", e)
}
TransactionManager.currentOrNull()?.rollback()
if (e.cause !is SQLException) {
throw e
}

View File

@ -45,6 +45,7 @@ class ActorRepositoryImpl(
it[followingCount] = actor.followingCount
it[postsCount] = actor.postsCount
it[lastPostAt] = actor.lastPostDate
it[emojis] = actor.emojis.joinToString(",")
}
} else {
Actors.update({ Actors.id eq actor.id }) {
@ -67,6 +68,7 @@ class ActorRepositoryImpl(
it[followingCount] = actor.followingCount
it[postsCount] = actor.postsCount
it[lastPostAt] = actor.lastPostDate
it[emojis] = actor.emojis.joinToString(",")
}
}
return@query actor
@ -152,7 +154,7 @@ object Actors : Table("actors") {
val followersCount = integer("followers_count")
val postsCount = integer("posts_count")
val lastPostAt = timestamp("last_post_at").nullable()
val emojis = varchar("emojis", 3000)
override val primaryKey: PrimaryKey = PrimaryKey(id)
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[isPureRepost] = timeline.isPureRepost
it[mediaIds] = timeline.mediaIds.joinToString(",")
it[emojiIds] = timeline.emojiIds.joinToString(",")
}
} else {
Timelines.update({ Timelines.id eq timeline.id }) {
@ -52,6 +53,7 @@ class ExposedTimelineRepository(private val idGenerateService: IdGenerateService
it[isLocal] = timeline.isLocal
it[isPureRepost] = timeline.isPureRepost
it[mediaIds] = timeline.mediaIds.joinToString(",")
it[emojiIds] = timeline.emojiIds.joinToString(",")
}
}
return@query timeline
@ -72,6 +74,7 @@ class ExposedTimelineRepository(private val idGenerateService: IdGenerateService
this[Timelines.isLocal] = it.isLocal
this[Timelines.isPureRepost] = it.isPureRepost
this[Timelines.mediaIds] = it.mediaIds.joinToString(",")
this[Timelines.emojiIds] = it.emojiIds.joinToString(",")
}
return@query timelines
}
@ -104,7 +107,8 @@ fun ResultRow.toTimeline(): Timeline {
sensitive = this[Timelines.sensitive],
isLocal = this[Timelines.isLocal],
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 isPureRepost = bool("is_pure_repost")
val mediaIds = varchar("media_ids", 255)
val emojiIds = varchar("emoji_ids", 255)
override val primaryKey: PrimaryKey = PrimaryKey(id)

View File

@ -41,14 +41,25 @@ class PostRepositoryImpl(
this[PostsMedia.postId] = post.id
this[PostsMedia.mediaId] = it
}
PostsEmojis.batchInsert(post.emojiIds) {
this[PostsEmojis.postId] = post.id
this[PostsEmojis.emojiId] = it
}
} else {
PostsMedia.deleteWhere {
postId eq post.id
}
PostsEmojis.deleteWhere {
postId eq post.id
}
PostsMedia.batchInsert(post.mediaIds) {
this[PostsMedia.postId] = post.id
this[PostsMedia.mediaId] = it
}
PostsEmojis.batchInsert(post.emojiIds) {
this[PostsEmojis.postId] = post.id
this[PostsEmojis.emojiId] = it
}
Posts.update({ Posts.id eq post.id }) {
it[actorId] = post.actorId
it[overview] = post.overview
@ -67,21 +78,27 @@ class PostRepositoryImpl(
}
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 }
.let(postQueryMapper::map)
.singleOrNull()
}
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 }
.let(postQueryMapper::map)
.singleOrNull()
}
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 }
.let(postQueryMapper::map)
.singleOrNull()
@ -92,7 +109,10 @@ class PostRepositoryImpl(
}
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 {
@ -125,3 +145,9 @@ object PostsMedia : Table("posts_media") {
val mediaId = long("media_id").references(Media.id, ReferenceOption.CASCADE, ReferenceOption.CASCADE)
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
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.ReactionRepository
import org.jetbrains.exposed.dao.id.LongIdTable
@ -23,13 +26,25 @@ class ReactionRepositoryImpl(
if (Reactions.select { Reactions.id eq reaction.id }.forUpdate().empty()) {
Reactions.insert {
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[actorId] = reaction.actorId
}
} else {
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[actorId] = reaction.actorId
}
@ -38,9 +53,16 @@ class ReactionRepositoryImpl(
}
override suspend fun delete(reaction: Reaction): Reaction = query {
Reactions.deleteWhere {
id.eq(reaction.id).and(postId.eq(reaction.postId)).and(actorId.eq(reaction.actorId))
.and(emojiId.eq(reaction.emojiId))
if (reaction.emoji is CustomEmoji) {
Reactions.deleteWhere {
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
}
@ -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 {
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? =
query {
return@query Reactions.select {
return@query Reactions.leftJoin(CustomEmojis).select {
Reactions.postId eq postId and (Reactions.actorId eq actorId).and(
Reactions.emojiId.eq(
Reactions.customEmojiId.eq(
emojiId
)
)
@ -78,12 +122,49 @@ class ReactionRepositoryImpl(
Reactions.postId
.eq(postId)
.and(Reactions.actorId.eq(actorId))
.and(Reactions.emojiId.eq(emojiId))
.and(Reactions.customEmojiId.eq(emojiId))
}.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 {
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() }
}
@ -93,22 +174,39 @@ class ReactionRepositoryImpl(
}
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(
this[Reactions.id].value,
this[Reactions.emojiId],
emoji,
this[Reactions.postId],
this[Reactions.actorId]
)
}
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> =
long("post_id").references(Posts.id, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val actorId: Column<Long> =
long("actor_id").references(Actors.id, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
init {
uniqueIndex(emojiId, postId, actorId)
uniqueIndex(customEmojiId, postId, actorId)
}
}

View File

@ -26,11 +26,6 @@ class UserDetailsImpl(
accountNonLocked: Boolean,
authorities: MutableCollection<out GrantedAuthority>?
) : User(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities) {
companion object {
@Serial
private const val serialVersionUID: Long = -899168205656607781L
}
override fun toString(): String {
return "UserDetailsImpl(" +
"id=$id" +
@ -53,6 +48,11 @@ class UserDetailsImpl(
result = 31 * result + id.hashCode()
return result
}
companion object {
@Serial
private const val serialVersionUID: Long = -899168205656607781L
}
}
@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
}
override fun hashCode(): Int {
return success.hashCode()
}
override fun hashCode(): Int = success.hashCode()
}
class SuccessSavedMedia(

View File

@ -1,11 +1,12 @@
package dev.usbharu.hideout.core.service.reaction
import dev.usbharu.hideout.core.domain.model.emoji.Emoji
import org.springframework.stereotype.Service
@Service
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 sendReaction(name: String, actorId: Long, postId: Long)
suspend fun sendReaction(emoji: Emoji, actorId: Long, postId: Long)
suspend fun removeReaction(actorId: Long, postId: Long)
}

View File

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

View File

@ -45,7 +45,8 @@ class ExposedGenerateTimelineService(private val statusQueryService: StatusQuery
it[Timelines.postId],
it[Timelines.replyId],
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.replyId,
it.repostId,
it.mediaIds
it.mediaIds,
it.emojiIds
)
}
)

View File

@ -37,7 +37,8 @@ class TimelineService(
sensitive = post.sensitive,
isLocal = isLocal,
isPureRepost = post.repostId == null || (post.text.isBlank() && post.overview.isNullOrBlank()),
mediaIds = post.mediaIds
mediaIds = post.mediaIds,
emojiIds = post.emojiIds
)
}.toMutableList()
if (post.visibility == Visibility.PUBLIC) {
@ -55,7 +56,8 @@ class TimelineService(
sensitive = post.sensitive,
isLocal = isLocal,
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
import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji
import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments
import dev.usbharu.hideout.core.infrastructure.exposedrepository.*
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.springframework.stereotype.Repository
import java.time.Instant
import dev.usbharu.hideout.domain.mastodon.model.generated.CustomEmoji as MastodonEmoji
@Suppress("IncompleteDestructuring")
@Repository
@ -23,6 +25,10 @@ class StatusQueryServiceImpl : StatusQueryService {
postIdSet.addAll(statusQueries.flatMap { listOfNotNull(it.postId, it.replyId, it.repostId) })
val mediaIdSet = mutableSetOf<Long>()
mediaIdSet.addAll(statusQueries.flatMap { it.mediaIds })
val emojiIdSet = mutableSetOf<Long>()
emojiIdSet.addAll(statusQueries.flatMap { it.emojiIds })
val postMap = Posts
.leftJoin(Actors)
.select { Posts.id inList postIdSet }
@ -32,12 +38,16 @@ class StatusQueryServiceImpl : StatusQueryService {
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 ->
postMap[statusQuery.postId]?.copy(
inReplyToId = statusQuery.replyId?.toString(),
inReplyToAccountId = postMap[statusQuery.replyId]?.account?.id,
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)
}
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> {
val statuses = pairs.map { it.first }
return pairs
@ -120,6 +149,8 @@ class StatusQueryServiceImpl : StatusQueryService {
private suspend fun findByPostIdsWithMedia(ids: List<Long>): List<Status> {
val pairs = Posts
.leftJoin(PostsMedia)
.leftJoin(PostsEmojis)
.leftJoin(CustomEmojis)
.leftJoin(Actors)
.leftJoin(Media)
.select { Posts.id inList ids }
@ -129,13 +160,22 @@ class StatusQueryServiceImpl : StatusQueryService {
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(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(
id = it[Posts.id].toString(),
uri = it[Posts.apId],

View File

@ -24,4 +24,20 @@ class MastodonStatusesApiContoller(private val statusesApiService: StatusesApiSe
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 replyId: 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,
includeFollowers: Boolean = false
): List<Status>
suspend fun findByPostId(id: Long): Status
}

View File

@ -1,17 +1,24 @@
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.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.toMediaAttachments
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.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.Visibility.*
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.toStatusVisibility
import dev.usbharu.hideout.mastodon.query.StatusQueryService
import dev.usbharu.hideout.mastodon.service.account.AccountService
import dev.usbharu.hideout.util.EmojiUtil
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.Instant
@ -22,16 +29,38 @@ interface StatusesApiService {
statusesRequest: StatusesRequest,
userId: Long
): 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
@Suppress("LongParameterList")
class StatsesApiServiceImpl(
private val postService: PostService,
private val accountService: AccountService,
private val mediaRepository: MediaRepository,
private val transaction: Transaction,
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 {
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 {
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
}
override fun hashCode(): Int {
return path?.hashCode() ?: 0
}
override fun hashCode(): Int = 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
(
id bigint primary key,
@ -13,6 +25,10 @@ create table if not exists instance
moderation_note varchar(10000) 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
(
id bigint primary key,
@ -34,7 +50,8 @@ create table if not exists actors
following_count int not null,
followers_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"),
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;
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;
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
(
id bigserial primary key,
emoji_id bigint not null,
post_id bigint not null,
actor_id bigint not null
id bigint primary key,
unicode_emoji varchar(255) null default null,
custom_emoji_id bigint null default null,
post_id bigint not null,
actor_id bigint not null,
unique (post_id, actor_id)
);
alter table reactions
add constraint fk_reactions_post_id__id foreign key (post_id) references posts (id) on delete restrict on update restrict;
alter table reactions
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
(
id bigint primary key,
user_id bigint not null,
timeline_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,
reply_id bigint null,
repost_id bigint null,
@ -124,7 +159,8 @@ create table if not exists timelines
"sensitive" boolean not null,
is_local 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

View File

@ -152,6 +152,79 @@ paths:
schema:
$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:
post:
tags:

View File

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

View File

@ -3,6 +3,7 @@ package dev.usbharu.hideout.activitypub.domain.model
import com.fasterxml.jackson.module.kotlin.readValue
import dev.usbharu.hideout.activitypub.service.objects.note.APNoteServiceImpl.Companion.public
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.Test
@ -51,11 +52,7 @@ class NoteSerializeTest {
"attachment": [],
"sensitive": false,
"tag": [
{
"type": "Mention",
"href": "https://calckey.jp/users/9bu1xzwjyb",
"name": "@trapezial@calckey.jp"
}
]
}"""
@ -77,4 +74,105 @@ class NoteSerializeTest {
)
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.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.reaction.Reaction
import dev.usbharu.hideout.core.external.job.DeliverReactionJob
@ -48,7 +49,7 @@ class APReactionServiceImplTest {
apReactionServiceImpl.reaction(
Reaction(
id = TwitterSnowflakeIdGenerateService.generateId(),
emojiId = 0,
emoji = UnicodeEmoji(""),
postId = post.id,
actorId = user.id
)
@ -88,7 +89,7 @@ class APReactionServiceImplTest {
apReactionServiceImpl.removeReaction(
Reaction(
id = TwitterSnowflakeIdGenerateService.generateId(),
emojiId = 0,
emoji = UnicodeEmoji(""),
postId = post.id,
actorId = user.id
)

View File

@ -73,6 +73,7 @@ class APNoteServiceImplTest {
apResourceResolveService = mock(),
postBuilder = Post.PostBuilder(CharacterLimit()),
noteQueryService = noteQueryService,
mock(),
mock()
)
@ -142,7 +143,8 @@ class APNoteServiceImplTest {
apResourceResolveService = apResourceResolveService,
postBuilder = Post.PostBuilder(CharacterLimit()),
noteQueryService = noteQueryService,
mock()
mock(),
mock { }
)
val actual = apNoteServiceImpl.fetchNote(url)
@ -190,6 +192,7 @@ class APNoteServiceImplTest {
apResourceResolveService = apResourceResolveService,
postBuilder = Post.PostBuilder(CharacterLimit()),
noteQueryService = noteQueryService,
mock(),
mock()
)
@ -240,6 +243,7 @@ class APNoteServiceImplTest {
apResourceResolveService = mock(),
postBuilder = postBuilder,
noteQueryService = noteQueryService,
mock(),
mock()
)
@ -292,6 +296,7 @@ class APNoteServiceImplTest {
apResourceResolveService = mock(),
postBuilder = postBuilder,
noteQueryService = noteQueryService,
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.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.ReactionRepository
import kotlinx.coroutines.test.runTest
import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks
@ -32,59 +32,33 @@ class ReactionServiceImplTest {
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
)
val generateId = TwitterSnowflakeIdGenerateService.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
fun `receiveReaction リアクションが既に作成されている場合は何もしない`() = runTest() {
fun `receiveReaction リアクションが既に作成されている場合削除して新しく作成`() = runTest() {
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
)
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
@ -96,10 +70,10 @@ class ReactionServiceImplTest {
val generateId = TwitterSnowflakeIdGenerateService.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(apReactionService, times(1)).reaction(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, UnicodeEmoji(""), post.id, post.actorId)))
}
@Test
@ -107,30 +81,30 @@ class ReactionServiceImplTest {
val post = PostBuilder.of()
val id = TwitterSnowflakeIdGenerateService.generateId()
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()
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)).save(eq(Reaction(generateId, 0, post.id, post.actorId)))
verify(apReactionService, times(1)).removeReaction(eq(Reaction(id, 0, post.id, post.actorId)))
verify(apReactionService, times(1)).reaction(eq(Reaction(generateId, 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, UnicodeEmoji(""), 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, UnicodeEmoji(""), post.id, post.actorId)))
}
@Test
fun `removeReaction リアクションが存在する場合削除して配送`() = runTest {
val post = PostBuilder.of()
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)
verify(reactionRepository, times(1)).delete(eq(Reaction(0, 0, post.id, post.actorId)))
verify(apReactionService, times(1)).removeReaction(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, 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()
}
}