diff --git a/src/main/kotlin/fr/shikkanime/controllers/admin/AdminController.kt b/src/main/kotlin/fr/shikkanime/controllers/admin/AdminController.kt index cf5b471c..c21c594b 100644 --- a/src/main/kotlin/fr/shikkanime/controllers/admin/AdminController.kt +++ b/src/main/kotlin/fr/shikkanime/controllers/admin/AdminController.kt @@ -7,8 +7,10 @@ import fr.shikkanime.entities.Anime import fr.shikkanime.entities.EpisodeMapping import fr.shikkanime.entities.EpisodeVariant import fr.shikkanime.entities.Simulcast +import fr.shikkanime.entities.enums.ConfigPropertyKey import fr.shikkanime.entities.enums.Link import fr.shikkanime.services.* +import fr.shikkanime.services.caches.ConfigCacheService import fr.shikkanime.services.caches.SimulcastCacheService import fr.shikkanime.utils.MapCache import fr.shikkanime.utils.routes.AdminSessionAuthenticated @@ -19,6 +21,7 @@ import fr.shikkanime.utils.routes.method.Get import fr.shikkanime.utils.routes.method.Post import fr.shikkanime.utils.routes.param.BodyParam import fr.shikkanime.utils.routes.param.QueryParam +import fr.shikkanime.wrappers.ThreadsWrapper import io.ktor.http.* import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking @@ -36,6 +39,9 @@ class AdminController { @Inject private lateinit var animeService: AnimeService + @Inject + private lateinit var configCacheService: ConfigCacheService + @Path @Get private fun home(@QueryParam("error") error: String?): Response { @@ -156,6 +162,45 @@ class AdminController { return Response.template(Link.TRACE_ACTIONS) } + @Path("/threads") + @Get + @AdminSessionAuthenticated + private fun getThreads( + @QueryParam("success") success: Int? + ): Response { + return Response.template( + Link.THREADS, + mapOf( + "askCodeUrl" to ThreadsWrapper.getCode( + requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_APP_ID)) + ), + "success" to success + ) + ) + } + + @Path("/threads-publish") + @Get + @AdminSessionAuthenticated + private fun threadsPublish( + @QueryParam("message") message: String, + @QueryParam("image_url") imageUrl: String?, + ): Response { + val hasImage = !imageUrl.isNullOrBlank() + + runBlocking { + ThreadsWrapper.post( + requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_ACCESS_TOKEN)), + if (hasImage) ThreadsWrapper.PostType.IMAGE else ThreadsWrapper.PostType.TEXT, + message, + imageUrl.takeIf { hasImage }, + "An example image".takeIf { hasImage } + ) + } + + return Response.redirect(Link.THREADS.href) + } + @Path("/config") @Get @AdminSessionAuthenticated diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/EpisodeMappingController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/EpisodeMappingController.kt index 042708ac..0392d3f2 100644 --- a/src/main/kotlin/fr/shikkanime/controllers/api/EpisodeMappingController.kt +++ b/src/main/kotlin/fr/shikkanime/controllers/api/EpisodeMappingController.kt @@ -148,12 +148,21 @@ class EpisodeMappingController : HasPageableRoute() { @Path("/{uuid}/media-image") @Get - @AdminSessionAuthenticated - @OpenAPI(hidden = true) - private fun getMediaImage(@PathParam("uuid") uuid: UUID): Response { + @OpenAPI( + "Get episode variant image published on social networks", + [ + OpenAPIResponse(200, "Image found", contentType = "image/jpeg"), + OpenAPIResponse(404, "Image not found") + ] + ) + private fun getMediaImage( + @PathParam("uuid", description = "UUID of the episode variant") uuid: UUID + ): Response { + val episodeVariant = episodeVariantService.find(uuid) ?: return Response.notFound() + val image = ImageService.toEpisodeImage( AbstractConverter.convert( - episodeVariantService.find(uuid), + episodeVariant, EpisodeVariantDto::class.java ) ) diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt index f54b9814..9e65f496 100644 --- a/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt +++ b/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt @@ -25,7 +25,7 @@ class MetricController { private fun getMetrics( @QueryParam("hours") hours: Int?, ): Response { - val oneHourAgo = ZonedDateTime.now().minusHours(hours?.toLong() ?: 1) - return Response.ok(AbstractConverter.convert(metricService.findAllAfter(oneHourAgo), MetricDto::class.java)) + val xHourAgo = ZonedDateTime.now().minusHours(hours?.toLong() ?: 1) + return Response.ok(AbstractConverter.convert(metricService.findAllAfter(xHourAgo), MetricDto::class.java)) } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/ThreadsCallbackController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/ThreadsCallbackController.kt new file mode 100644 index 00000000..a046a67b --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/controllers/api/ThreadsCallbackController.kt @@ -0,0 +1,64 @@ +package fr.shikkanime.controllers.api + +import com.google.inject.Inject +import fr.shikkanime.entities.Config +import fr.shikkanime.entities.enums.ConfigPropertyKey +import fr.shikkanime.entities.enums.Link +import fr.shikkanime.services.ConfigService +import fr.shikkanime.services.caches.ConfigCacheService +import fr.shikkanime.utils.MapCache +import fr.shikkanime.utils.routes.AdminSessionAuthenticated +import fr.shikkanime.utils.routes.Controller +import fr.shikkanime.utils.routes.Path +import fr.shikkanime.utils.routes.Response +import fr.shikkanime.utils.routes.method.Get +import fr.shikkanime.utils.routes.openapi.OpenAPI +import fr.shikkanime.utils.routes.param.QueryParam +import fr.shikkanime.wrappers.ThreadsWrapper +import kotlinx.coroutines.runBlocking + +@Controller("/api/threads") +class ThreadsCallbackController { + @Inject + private lateinit var configCacheService: ConfigCacheService + + @Inject + private lateinit var configService: ConfigService + + @Path + @Get + @AdminSessionAuthenticated + @OpenAPI(hidden = true) + private fun callback( + @QueryParam("code") code: String, + ): Response { + val appId = requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_APP_ID)) + val appSecret = requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_APP_SECRET)) + + return runBlocking { + try { + val accessToken = ThreadsWrapper.getAccessToken( + appId, + appSecret, + code, + ) + + val longLivedAccessToken = ThreadsWrapper.getLongLivedAccessToken( + appSecret, + accessToken, + ) + + configService.findByName(ConfigPropertyKey.THREADS_ACCESS_TOKEN.key)?.let { + it.propertyValue = longLivedAccessToken + configService.update(it) + MapCache.invalidate(Config::class.java) + } + + Response.redirect("${Link.THREADS.href}?success=1") + } catch (e: Exception) { + e.printStackTrace() + Response.badRequest("Impossible to get token with code $code") + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/entities/enums/ConfigPropertyKey.kt b/src/main/kotlin/fr/shikkanime/entities/enums/ConfigPropertyKey.kt index 3fb1f33c..e52645c4 100644 --- a/src/main/kotlin/fr/shikkanime/entities/enums/ConfigPropertyKey.kt +++ b/src/main/kotlin/fr/shikkanime/entities/enums/ConfigPropertyKey.kt @@ -3,24 +3,44 @@ package fr.shikkanime.entities.enums enum class ConfigPropertyKey(val key: String) { SIMULCAST_RANGE("simulcast_range"), DISCORD_TOKEN("discord_token"), - TWITTER_CONSUMER_KEY("twitter_consumer_key"), - TWITTER_CONSUMER_SECRET("twitter_consumer_secret"), - TWITTER_ACCESS_TOKEN("twitter_access_token"), - TWITTER_ACCESS_TOKEN_SECRET("twitter_access_token_secret"), SEO_DESCRIPTION("seo_description"), SOCIAL_NETWORK_EPISODES_SIZE_LIMIT("social_network_episodes_size_limit"), GOOGLE_SITE_VERIFICATION_ID("google_site_verification_id"), CHECK_CRUNCHYROLL_SIMULCASTS("check_crunchyroll_simulcasts"), - BSKY_IDENTIFIER("bsky_identifier"), - BSKY_PASSWORD("bsky_password"), + + // Twitter API + TWITTER_CONSUMER_KEY("twitter_consumer_key"), + TWITTER_CONSUMER_SECRET("twitter_consumer_secret"), + TWITTER_ACCESS_TOKEN("twitter_access_token"), + TWITTER_ACCESS_TOKEN_SECRET("twitter_access_token_secret"), TWITTER_FIRST_MESSAGE("twitter_first_message"), TWITTER_SECOND_MESSAGE("twitter_second_message"), + + // Threads old API + @Deprecated("Use Threads API") THREADS_USERNAME("threads_username"), + + @Deprecated("Use Threads API") THREADS_PASSWORD("threads_password"), - THREADS_MESSAGE("threads_message"), - BSKY_MESSAGE("bsky_message"), - BSKY_SESSION_TIMEOUT("bsky_session_timeout"), + + @Deprecated("Use Threads API") THREADS_SESSION_TIMEOUT("threads_session_timeout"), + + // Threads API + USE_NEW_THREADS_WRAPPER("use_new_threads_wrapper"), + THREADS_APP_ID("threads_app_id"), + THREADS_APP_SECRET("threads_app_secret"), + THREADS_ACCESS_TOKEN("threads_access_token"), + THREADS_FIRST_MESSAGE("threads_first_message"), + THREADS_SECOND_MESSAGE("threads_second_message"), + + // Bsky API + BSKY_IDENTIFIER("bsky_identifier"), + BSKY_PASSWORD("bsky_password"), + BSKY_SESSION_TIMEOUT("bsky_session_timeout"), + BSKY_FIRST_MESSAGE("bsky_first_message"), + BSKY_SECOND_MESSAGE("bsky_second_message"), + SIMULCAST_RANGE_DELAY("simulcast_range_delay"), CRUNCHYROLL_FETCH_API_SIZE("crunchyroll_fetch_api_size"), ANIMATION_DITIGAL_NETWORK_SIMULCAST_DETECTION_REGEX("animation_digital_network_simulcast_detection_regex"), diff --git a/src/main/kotlin/fr/shikkanime/entities/enums/Link.kt b/src/main/kotlin/fr/shikkanime/entities/enums/Link.kt index 56290626..8185d1ae 100644 --- a/src/main/kotlin/fr/shikkanime/entities/enums/Link.kt +++ b/src/main/kotlin/fr/shikkanime/entities/enums/Link.kt @@ -16,6 +16,7 @@ enum class Link( ANIMES("/admin/animes", "/admin/animes/list.ftl", "bi bi-file-earmark-play", "Animes"), EPISODES("/admin/episodes", "/admin/episodes/list.ftl", "bi bi-collection-play", "Episodes"), TRACE_ACTIONS("/admin/trace-actions", "/admin/trace-actions.ftl", "bi bi-database-exclamation", "Trace actions"), + THREADS("/admin/threads", "/admin/threads.ftl", "bi bi-threads", "Threads"), CONFIG("/admin/config", "/admin/configs.ftl", "bi bi-gear", "Configurations"), // Site diff --git a/src/main/kotlin/fr/shikkanime/socialnetworks/AbstractSocialNetwork.kt b/src/main/kotlin/fr/shikkanime/socialnetworks/AbstractSocialNetwork.kt index 87435d0e..f8617892 100644 --- a/src/main/kotlin/fr/shikkanime/socialnetworks/AbstractSocialNetwork.kt +++ b/src/main/kotlin/fr/shikkanime/socialnetworks/AbstractSocialNetwork.kt @@ -1,10 +1,10 @@ package fr.shikkanime.socialnetworks import com.google.inject.Inject -import fr.shikkanime.dtos.PlatformDto import fr.shikkanime.dtos.variants.EpisodeVariantDto import fr.shikkanime.entities.enums.EpisodeType import fr.shikkanime.entities.enums.LangType +import fr.shikkanime.entities.enums.Platform import fr.shikkanime.services.caches.ConfigCacheService import fr.shikkanime.utils.Constant import fr.shikkanime.utils.StringUtils @@ -17,9 +17,7 @@ abstract class AbstractSocialNetwork { abstract fun login() abstract fun logout() - open fun platformAccount(platform: PlatformDto): String { - return platform.name - } + open fun platformAccount(platform: Platform) = platform.platformName private fun information(episodeDto: EpisodeVariantDto): String { return when (episodeDto.mapping.episodeType) { @@ -41,7 +39,8 @@ abstract class AbstractSocialNetwork { var configMessage = baseMessage configMessage = configMessage.replace("{SHIKKANIME_URL}", getShikkanimeUrl(episodeDto)) configMessage = configMessage.replace("{URL}", episodeDto.url) - configMessage = configMessage.replace("{PLATFORM_ACCOUNT}", platformAccount(episodeDto.platform)) + configMessage = + configMessage.replace("{PLATFORM_ACCOUNT}", platformAccount(Platform.valueOf(episodeDto.platform.id))) configMessage = configMessage.replace("{PLATFORM_NAME}", episodeDto.platform.name) configMessage = configMessage.replace("{ANIME_HASHTAG}", "#${StringUtils.getHashtag(episodeDto.mapping.anime.shortName)}") configMessage = configMessage.replace("{ANIME_TITLE}", episodeDto.mapping.anime.shortName) diff --git a/src/main/kotlin/fr/shikkanime/socialnetworks/BskySocialNetwork.kt b/src/main/kotlin/fr/shikkanime/socialnetworks/BskySocialNetwork.kt index 14abd071..a1ed1de0 100644 --- a/src/main/kotlin/fr/shikkanime/socialnetworks/BskySocialNetwork.kt +++ b/src/main/kotlin/fr/shikkanime/socialnetworks/BskySocialNetwork.kt @@ -64,14 +64,18 @@ class BskySocialNetwork : AbstractSocialNetwork() { override fun sendEpisodeRelease(episodeDto: EpisodeVariantDto, mediaImage: ByteArray?) { checkSession() if (!isInitialized) return - val message = - getEpisodeMessage(episodeDto, configCacheService.getValueAsString(ConfigPropertyKey.BSKY_MESSAGE) ?: "") - runBlocking { + val firstMessage = + getEpisodeMessage( + episodeDto, + configCacheService.getValueAsString(ConfigPropertyKey.BSKY_FIRST_MESSAGE) ?: "" + ) + + val firstRecord = runBlocking { BskyWrapper.createRecord( accessJwt!!, did!!, - message, + firstMessage, mediaImage?.let { listOf( BskyWrapper.Image( @@ -85,5 +89,19 @@ class BskySocialNetwork : AbstractSocialNetwork() { } ?: emptyList() ) } + + val secondMessage = configCacheService.getValueAsString(ConfigPropertyKey.BSKY_SECOND_MESSAGE) + + if (!secondMessage.isNullOrBlank()) { + runBlocking { + BskyWrapper.createRecord( + accessJwt!!, + did!!, + getEpisodeMessage(episodeDto, secondMessage.replace("{EMBED}", "")).trim(), + replyTo = firstRecord, + embed = getShikkanimeUrl(episodeDto).takeIf { secondMessage.contains("{EMBED}") } + ) + } + } } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/socialnetworks/ThreadsSocialNetwork.kt b/src/main/kotlin/fr/shikkanime/socialnetworks/ThreadsSocialNetwork.kt index 44c93993..8da32e67 100644 --- a/src/main/kotlin/fr/shikkanime/socialnetworks/ThreadsSocialNetwork.kt +++ b/src/main/kotlin/fr/shikkanime/socialnetworks/ThreadsSocialNetwork.kt @@ -1,9 +1,11 @@ package fr.shikkanime.socialnetworks -import fr.shikkanime.dtos.PlatformDto import fr.shikkanime.dtos.variants.EpisodeVariantDto import fr.shikkanime.entities.enums.ConfigPropertyKey +import fr.shikkanime.entities.enums.Platform +import fr.shikkanime.utils.Constant import fr.shikkanime.utils.LoggerFactory +import fr.shikkanime.wrappers.OldThreadsWrapper import fr.shikkanime.wrappers.ThreadsWrapper import kotlinx.coroutines.runBlocking import java.time.ZonedDateTime @@ -11,7 +13,7 @@ import java.util.logging.Level class ThreadsSocialNetwork : AbstractSocialNetwork() { private val logger = LoggerFactory.getLogger(ThreadsSocialNetwork::class.java) - private val threadsWrapper = ThreadsWrapper() + private val oldThreadsWrapper = OldThreadsWrapper() private var isInitialized = false private var initializedAt: ZonedDateTime? = null @@ -27,16 +29,21 @@ class ThreadsSocialNetwork : AbstractSocialNetwork() { if (isInitialized) return try { - val username = requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_USERNAME)) - val password = requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_PASSWORD)) - if (username.isBlank() || password.isBlank()) throw Exception("Username or password is empty") - val generateDeviceId = threadsWrapper.generateDeviceId(username, password) - val (token, userId) = runBlocking { threadsWrapper.login(generateDeviceId, username, password) } - - this.username = username - this.deviceId = generateDeviceId - this.token = token - this.userId = userId + if (configCacheService.getValueAsBoolean(ConfigPropertyKey.USE_NEW_THREADS_WRAPPER)) { + this.token = requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_ACCESS_TOKEN)) + } else { + val username = requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_USERNAME)) + val password = requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_PASSWORD)) + if (username.isBlank() || password.isBlank()) throw Exception("Username or password is empty") + val generateDeviceId = oldThreadsWrapper.generateDeviceId(username, password) + val (token, userId) = runBlocking { oldThreadsWrapper.login(generateDeviceId, username, password) } + + this.username = username + this.deviceId = generateDeviceId + this.token = token + this.userId = userId + } + isInitialized = true initializedAt = ZonedDateTime.now() } catch (e: Exception) { @@ -53,26 +60,25 @@ class ThreadsSocialNetwork : AbstractSocialNetwork() { } private fun checkSession() { - if (isInitialized && - initializedAt != null && - initializedAt!!.plusMinutes( - configCacheService.getValueAsInt(ConfigPropertyKey.THREADS_SESSION_TIMEOUT, 10).toLong() - ).isAfter(ZonedDateTime.now()) - ) { - return - } + val useNewWrapper = configCacheService.getValueAsBoolean(ConfigPropertyKey.USE_NEW_THREADS_WRAPPER) + val sessionTimeout = configCacheService.getValueAsInt(ConfigPropertyKey.THREADS_SESSION_TIMEOUT, 10).toLong() + + if (useNewWrapper && isInitialized) return + if (!useNewWrapper && isInitialized && initializedAt?.plusMinutes(sessionTimeout) + ?.isAfter(ZonedDateTime.now()) == true + ) return logout() login() } - override fun platformAccount(platform: PlatformDto): String { - return when (platform.id) { - "CRUN" -> "@crunchyroll_fr" - "DISN" -> "@disneyplus" - "NETF" -> "@netflixfr" - "PRIM" -> "@primevideofr" - else -> platform.name + override fun platformAccount(platform: Platform): String { + return when (platform) { + Platform.CRUN -> "@crunchyroll_fr" + Platform.DISN -> "@disneyplus" + Platform.NETF -> "@netflixfr" + Platform.PRIM -> "@primevideofr" + else -> platform.platformName } } @@ -80,7 +86,34 @@ class ThreadsSocialNetwork : AbstractSocialNetwork() { checkSession() if (!isInitialized) return val message = - getEpisodeMessage(episodeDto, configCacheService.getValueAsString(ConfigPropertyKey.THREADS_MESSAGE) ?: "") - runBlocking { threadsWrapper.publish(username!!, deviceId!!, userId!!, token!!, message, mediaImage) } + getEpisodeMessage( + episodeDto, + configCacheService.getValueAsString(ConfigPropertyKey.THREADS_FIRST_MESSAGE) ?: "" + ) + + runBlocking { + if (configCacheService.getValueAsBoolean(ConfigPropertyKey.USE_NEW_THREADS_WRAPPER)) { + val firstPost = ThreadsWrapper.post( + token!!, + ThreadsWrapper.PostType.IMAGE, + message, + imageUrl = "${Constant.apiUrl}/v1/episode-mappings/${episodeDto.uuid}/media-image", + altText = "Image de l'épisode ${episodeDto.mapping.number} de ${episodeDto.mapping.anime.shortName}" + ) + + val secondMessage = configCacheService.getValueAsString(ConfigPropertyKey.THREADS_SECOND_MESSAGE) + + if (!secondMessage.isNullOrBlank()) { + ThreadsWrapper.post( + token!!, + ThreadsWrapper.PostType.TEXT, + getEpisodeMessage(episodeDto, secondMessage), + replyToId = firstPost + ) + } + } else { + oldThreadsWrapper.publish(username!!, deviceId!!, userId!!, token!!, message, mediaImage) + } + } } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/socialnetworks/TwitterSocialNetwork.kt b/src/main/kotlin/fr/shikkanime/socialnetworks/TwitterSocialNetwork.kt index 66f872ec..505ef737 100644 --- a/src/main/kotlin/fr/shikkanime/socialnetworks/TwitterSocialNetwork.kt +++ b/src/main/kotlin/fr/shikkanime/socialnetworks/TwitterSocialNetwork.kt @@ -1,8 +1,8 @@ package fr.shikkanime.socialnetworks -import fr.shikkanime.dtos.PlatformDto import fr.shikkanime.dtos.variants.EpisodeVariantDto import fr.shikkanime.entities.enums.ConfigPropertyKey +import fr.shikkanime.entities.enums.Platform import fr.shikkanime.utils.LoggerFactory import twitter4j.Twitter import twitter4j.TwitterFactory @@ -62,14 +62,14 @@ class TwitterSocialNetwork : AbstractSocialNetwork() { } } - override fun platformAccount(platform: PlatformDto): String { - return when (platform.id) { - "ANIM" -> "@ADNanime" - "CRUN" -> "@Crunchyroll_fr" - "NETF" -> "@NetflixFR" - "DISN" -> "@DisneyPlusFR" - "PRIM" -> "@PrimeVideoFR" - else -> platform.name + override fun platformAccount(platform: Platform): String { + return when (platform) { + Platform.ANIM -> "@ADNanime" + Platform.CRUN -> "@Crunchyroll_fr" + Platform.NETF -> "@NetflixFR" + Platform.DISN -> "@DisneyPlusFR" + Platform.PRIM -> "@PrimeVideoFR" + else -> platform.platformName } } diff --git a/src/main/kotlin/fr/shikkanime/utils/HttpRequest.kt b/src/main/kotlin/fr/shikkanime/utils/HttpRequest.kt index 23432ae2..dff589ea 100644 --- a/src/main/kotlin/fr/shikkanime/utils/HttpRequest.kt +++ b/src/main/kotlin/fr/shikkanime/utils/HttpRequest.kt @@ -58,7 +58,7 @@ class HttpRequest( return response } - suspend fun post(url: String, headers: Map = emptyMap(), body: Any): HttpResponse { + suspend fun post(url: String, headers: Map = emptyMap(), body: Any? = null): HttpResponse { val httpClient = httpClient() logger.info("Making request to $url... (POST)") val start = System.currentTimeMillis() @@ -67,7 +67,7 @@ class HttpRequest( header(key, value) } - setBody(body) + body?.let { setBody(it) } } httpClient.close() logger.info("Request to $url done in ${System.currentTimeMillis() - start}ms (POST)") diff --git a/src/main/kotlin/fr/shikkanime/wrappers/BskyWrapper.kt b/src/main/kotlin/fr/shikkanime/wrappers/BskyWrapper.kt index b15572eb..197820a6 100644 --- a/src/main/kotlin/fr/shikkanime/wrappers/BskyWrapper.kt +++ b/src/main/kotlin/fr/shikkanime/wrappers/BskyWrapper.kt @@ -5,10 +5,12 @@ import fr.shikkanime.utils.HttpRequest import fr.shikkanime.utils.ObjectParser import io.ktor.client.statement.* import io.ktor.http.* +import org.jsoup.Jsoup import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.regex.Pattern +import java.util.stream.Collectors private const val TYPE = "\$type" @@ -18,10 +20,22 @@ object BskyWrapper { val alt: String = "", ) + enum class FacetType(val typeKey: String, val jsonKey: String) { + LINK("link", "uri"), + HASHTAG("tag", "tag"), + ; + } + data class Facet( - val link: String, val start: Int, val end: Int, + val link: String, + val type: FacetType + ) + + data class Record( + val uri: String, + val cid: String, ) private const val BASE_URL = "https://bsky.social/xrpc" @@ -68,7 +82,9 @@ object BskyWrapper { did: String, text: String, images: List = emptyList(), - ): JsonObject { + replyTo: Record? = null, + embed: String? = null + ): Record { val (finalText, facets) = getFacets(text) val recordMap = mutableMapOf( @@ -94,14 +110,40 @@ object BskyWrapper { ), "features" to listOf( mapOf( - TYPE to "app.bsky.richtext.facet#link", - "uri" to it.link + TYPE to "app.bsky.richtext.facet#${it.type.typeKey}", + it.type.jsonKey to it.link ) ) ) } } + if (replyTo != null) { + recordMap["reply"] = mapOf( + "root" to replyTo, + "parent" to replyTo + ) + } + + if (embed != null) { + val document = HttpRequest().use { Jsoup.parse(it.get(embed).bodyAsText()) } + + recordMap["embed"] = mapOf( + "\$type" to "app.bsky.embed.external", + "external" to mapOf( + "uri" to embed, + "title" to document.select("meta[property=og:title]").attr("content"), + "description" to document.select("meta[property=og:description]").attr("content"), + "thumb" to uploadBlob( + accessJwt, + ContentType.Image.JPEG, + HttpRequest().use { it.get(document.select("meta[property=og:image]").attr("content")) } + .readBytes() + ) + ) + ) + } + val response = httpRequest.post( "$BASE_URL/com.atproto.repo.createRecord", headers = mapOf( @@ -119,36 +161,49 @@ object BskyWrapper { ) require(response.status.value == 200) { "Failed to create record (${response.bodyAsText()})" } - return ObjectParser.fromJson(response.bodyAsText()) + return ObjectParser.fromJson(response.bodyAsText(), Record::class.java) } + private fun countEmoji(text: String) = Pattern.compile("\\p{So}+") + .matcher(text) + .results() + .collect(Collectors.toUnmodifiableList()) + private fun getFacets(text: String): Pair> { var tmpText = text - val facets = text.split(" ").mapNotNull { word -> - val link = word.trim() - - if (link.startsWith("http")) { - val beautifulLink = link.replace("https?://www\\.|\\?.*".toRegex(), "").trim() - tmpText = tmpText.replace(link, beautifulLink) - - // Count \n before the link - val newLineCount = tmpText.substring(0, tmpText.indexOf(beautifulLink)).count { it == '\n' } - // Count the number of emojis before the link - val p = Pattern.compile("\\p{So}+") - val m = p.matcher(tmpText.substring(0, tmpText.indexOf(beautifulLink))) - var emojiCount = 0 - - while (m.find()) { - emojiCount++ + val facets = text.split(" ", "\n") + .mapNotNull { word -> + val link = word.trim() + when { + link.startsWith("http") -> { + val beautifulLink = link.replace("https?://www\\.|\\?.*".toRegex(), "").trim() + tmpText = tmpText.replace(link, beautifulLink) + + val emojiCount = countEmoji(tmpText.substringBeforeLast(beautifulLink))!! + val added = + if (emojiCount.isNotEmpty()) emojiCount.sumOf { it!!.group().length } + emojiCount.size else 0 + + val start = tmpText.indexOf(beautifulLink) + added + val end = start + beautifulLink.length + Facet(start, end, link, FacetType.LINK) + } + + link.startsWith("#") -> { + val emojiCount = countEmoji(tmpText.substringBeforeLast(link))!! + val added = + if (emojiCount.isNotEmpty()) emojiCount.sumOf { it!!.group().length } + emojiCount.size else 1 + + val start = tmpText.indexOf(link) + added + val end = start + link.length + Facet(start, end, link.substring(1), FacetType.HASHTAG) + } + + else -> null } - - val start = tmpText.indexOf(beautifulLink) + (((newLineCount + emojiCount) * 2) - 1) - val end = start + beautifulLink.length - Facet(link, start, end) - } else null - } + } return tmpText to facets } + } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/wrappers/OldThreadsWrapper.kt b/src/main/kotlin/fr/shikkanime/wrappers/OldThreadsWrapper.kt new file mode 100644 index 00000000..80e45a8a --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/wrappers/OldThreadsWrapper.kt @@ -0,0 +1,285 @@ +package fr.shikkanime.wrappers + +import fr.shikkanime.utils.EncryptionManager +import fr.shikkanime.utils.HttpRequest +import fr.shikkanime.utils.ObjectParser +import fr.shikkanime.utils.ObjectParser.getAsString +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.utils.io.core.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.net.URLEncoder +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.SecureRandom +import java.security.spec.X509EncodedKeySpec +import java.time.ZonedDateTime +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.math.abs + +private const val BASE_URL = "https://i.instagram.com" +private const val LATEST_APP_VERSION = "291.0.0.31.111" +private const val EXPERIMENTS = + "ig_android_fci_onboarding_friend_search,ig_android_device_detection_info_upload,ig_android_account_linking_upsell_universe,ig_android_direct_main_tab_universe_v2,ig_android_allow_account_switch_once_media_upload_finish_universe,ig_android_sign_in_help_only_one_account_family_universe,ig_android_sms_retriever_backtest_universe,ig_android_direct_add_direct_to_android_native_photo_share_sheet,ig_android_spatial_account_switch_universe,ig_growth_android_profile_pic_prefill_with_fb_pic_2,ig_account_identity_logged_out_signals_global_holdout_universe,ig_android_prefill_main_account_username_on_login_screen_universe,ig_android_login_identifier_fuzzy_match,ig_android_mas_remove_close_friends_entrypoint,ig_android_shared_email_reg_universe,ig_android_video_render_codec_low_memory_gc,ig_android_custom_transitions_universe,ig_android_push_fcm,multiple_account_recovery_universe,ig_android_show_login_info_reminder_universe,ig_android_email_fuzzy_matching_universe,ig_android_one_tap_aymh_redesign_universe,ig_android_direct_send_like_from_notification,ig_android_suma_landing_page,ig_android_prefetch_debug_dialog,ig_android_smartlock_hints_universe,ig_android_black_out,ig_activation_global_discretionary_sms_holdout,ig_android_video_ffmpegutil_pts_fix,ig_android_multi_tap_login_new,ig_save_smartlock_universe,ig_android_caption_typeahead_fix_on_o_universe,ig_android_enable_keyboardlistener_redesign,ig_android_sign_in_password_visibility_universe,ig_android_nux_add_email_device,ig_android_direct_remove_view_mode_stickiness_universe,ig_android_hide_contacts_list_in_nux,ig_android_new_users_one_tap_holdout_universe,ig_android_ingestion_video_support_hevc_decoding,ig_android_mas_notification_badging_universe,ig_android_secondary_account_in_main_reg_flow_universe,ig_android_secondary_account_creation_universe,ig_android_account_recovery_auto_login,ig_android_pwd_encrytpion,ig_android_bottom_sheet_keyboard_leaks,ig_android_sim_info_upload,ig_android_mobile_http_flow_device_universe,ig_android_hide_fb_button_when_not_installed_universe,ig_android_account_linking_on_concurrent_user_session_infra_universe,ig_android_targeted_one_tap_upsell_universe,ig_android_gmail_oauth_in_reg,ig_android_account_linking_flow_shorten_universe,ig_android_vc_interop_use_test_igid_universe,ig_android_notification_unpack_universe,ig_android_registration_confirmation_code_universe,ig_android_device_based_country_verification,ig_android_log_suggested_users_cache_on_error,ig_android_reg_modularization_universe,ig_android_device_verification_separate_endpoint,ig_android_universe_noticiation_channels,ig_android_account_linking_universe,ig_android_hsite_prefill_new_carrier,ig_android_one_login_toast_universe,ig_android_retry_create_account_universe,ig_android_family_apps_user_values_provider_universe,ig_android_reg_nux_headers_cleanup_universe,ig_android_mas_ui_polish_universe,ig_android_device_info_foreground_reporting,ig_android_shortcuts_2019,ig_android_device_verification_fb_signup,ig_android_onetaplogin_optimization,ig_android_passwordless_account_password_creation_universe,ig_android_black_out_toggle_universe,ig_video_debug_overlay,ig_android_ask_for_permissions_on_reg,ig_assisted_login_universe,ig_android_security_intent_switchoff,ig_android_device_info_job_based_reporting,ig_android_add_account_button_in_profile_mas_universe,ig_android_add_dialog_when_delinking_from_child_account_universe,ig_android_passwordless_auth,ig_radio_button_universe_2,ig_android_direct_main_tab_account_switch,ig_android_recovery_one_tap_holdout_universe,ig_android_modularized_dynamic_nux_universe,ig_android_fb_account_linking_sampling_freq_universe,ig_android_fix_sms_read_lollipop,ig_android_access_flow_prefil" +private const val BLOKS_VERSION = "5f56efad68e1edec7801f630b5c122704ec5378adbee6609a448f105f34a9c73" +private const val CONTENT_TYPE = "application/x-www-form-urlencoded; charset=UTF-8" +private const val USER_AGENT = "Barcelona $LATEST_APP_VERSION Android" + +@Deprecated("Use ThreadsWrapper instead") +class OldThreadsWrapper( + private val context: CoroutineDispatcher = Dispatchers.IO, +) { + private val httpRequest = HttpRequest() + private val secureRandom = SecureRandom() + + suspend fun qeSync(): HttpResponse { + val uuid = UUID.randomUUID().toString() + + return httpRequest.post( + "$BASE_URL/api/v1/qe/sync/", + headers = mapOf( + HttpHeaders.UserAgent to USER_AGENT, + HttpHeaders.ContentType to CONTENT_TYPE, + "Sec-Fetch-Site" to "same-origin", + "X-DEVICE-ID" to uuid, + ), + body = FormDataContent(Parameters.build { + append("id", uuid) + append("experiments", EXPERIMENTS) + }) + ) + } + + suspend fun encryptPassword(password: String): Map { + // https://github.com/instagram4j/instagram4j/blob/39635974c391e21a322ab3294275df99d7f75f84/src/main/java/com/github/instagram4j/instagram4j/utils/IGUtils.java#L176 + val randKey = ByteArray(32).also { secureRandom.nextBytes(it) } + val iv = ByteArray(12).also { secureRandom.nextBytes(it) } + val response = qeSync() + require(response.status.value == 200) { "Failed to get qeSync: ${response.status}" } + val headers = response.headers + val time = (System.currentTimeMillis() / 1000).toString() + + val passwordEncryptionKeyID = headers["ig-set-password-encryption-key-id"]!!.toInt() + val passwordEncryptionPubKey = headers["ig-set-password-encryption-pub-key"]!! + + // Encrypt random key + val decodedPubKey = String( + Base64.getDecoder().decode(passwordEncryptionPubKey), + StandardCharsets.UTF_8 + ).replace("-(.*)-|\n".toRegex(), "") + val rsaCipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING") // NOSONAR + rsaCipher.init( + Cipher.ENCRYPT_MODE, + KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(decodedPubKey))) + ) + val randKeyEncrypted = rsaCipher.doFinal(randKey) + + // Encrypt password + val aesGcmCipher = Cipher.getInstance("AES/GCM/NoPadding") + aesGcmCipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(randKey, "AES"), GCMParameterSpec(128, iv)) + aesGcmCipher.updateAAD(time.toByteArray()) + val passwordEncrypted = aesGcmCipher.doFinal(password.toByteArray()) + + // Write to final byte array + val out = ByteArrayOutputStream() + out.write(1) + out.write(Integer.valueOf(passwordEncryptionKeyID)) + + withContext(context) { + out.write(iv) + out.write( + ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putChar(randKeyEncrypted.size.toChar()).array() + ) + out.write(randKeyEncrypted) + out.write(Arrays.copyOfRange(passwordEncrypted, passwordEncrypted.size - 16, passwordEncrypted.size)) + out.write(Arrays.copyOfRange(passwordEncrypted, 0, passwordEncrypted.size - 16)) + } + + return mapOf( + "time" to time, + "password" to Base64.getEncoder().encodeToString(out.toByteArray()) + ) + } + + fun generateDeviceId(username: String, password: String): String { + val seed: String = EncryptionManager.toMD5(username + password) + val volatileSeed = "12345" + return "android-" + EncryptionManager.toMD5(seed + volatileSeed).substring(0, 16) + } + + suspend fun login(deviceId: String, username: String, password: String): Pair { + val encryptedPassword = encryptPassword(password) + + val params = URLEncoder.encode( + ObjectParser.toJson( + mapOf( + "client_input_params" to mapOf( + "password" to "#PWD_INSTAGRAM:4:${encryptedPassword["time"]}:${encryptedPassword["password"]}", + "contact_point" to username, + "device_id" to deviceId, + ), + "server_params" to mapOf( + "credential_type" to "password", + "device_id" to deviceId, + ) + ) + ), StandardCharsets.UTF_8 + ) + + val bkClientContext = URLEncoder.encode( + ObjectParser.toJson( + mapOf( + "bloks_version" to BLOKS_VERSION, + "styles_id" to "instagram", + ) + ), StandardCharsets.UTF_8 + ) + + val response = httpRequest.post( + "$BASE_URL/api/v1/bloks/apps/com.bloks.www.bloks.caa.login.async.send_login_request/", + headers = mapOf( + HttpHeaders.UserAgent to USER_AGENT, + HttpHeaders.ContentType to CONTENT_TYPE, + "Response-Type" to "json", + ), + body = "params=$params&bk_client_context=$bkClientContext&bloks_versioning_id=$BLOKS_VERSION" + ) + + require(response.status.value == 200) { "Failed to login: ${response.status}" } + + val responseJson = ObjectParser.fromJson(response.bodyAsText()) + val rawBloks = responseJson.getAsJsonObject("layout").getAsJsonObject("bloks_payload").getAsJsonObject("tree") + .getAsJsonObject("㐟").getAsString("#") + val substring = + rawBloks!!.substring(rawBloks.indexOfFirst { it == '{' }, rawBloks.indexOfLast { it == '}' } + 1) + + val sToken = substring.split("Bearer IGT:2:")[1] + val token = sToken.substring(0, sToken.indexOf("\\\\\\\"")) + + val sUserID = substring.split("pk_id")[1].replace("\\\\\\\":\\\\\\\"", "\":\"") + val userID = sUserID.substring(3, sUserID.indexOf("\\\\\\\"")) + + return token to userID + } + + private suspend fun uploadImage( + username: String, + token: String, + mimeType: ContentType, + uploadId: String, + content: ByteArray, + ): HttpResponse { + val name = "${uploadId}_0_${abs(secureRandom.nextInt())}" + + val map = mapOf( + "upload_id" to uploadId, + "media_type" to "1", + "sticker_burnin_params" to "[]", + "image_compression" to ObjectParser.toJson( + mapOf( + "lib_name" to "moz", + "lib_version" to "3.1.m", + "quality" to "80" + ) + ), + "xsharing_user_ids" to "[]", + "retry_context" to ObjectParser.toJson( + mapOf( + "num_step_auto_retry" to 0, + "num_reupload" to 0, + "num_step_manual_retry" to 0 + ) + ), + "IG-FB-Xpost-entry-point-v2" to "feed", + ) + + val imageHeaders = mapOf( + HttpHeaders.UserAgent to USER_AGENT, + HttpHeaders.ContentType to "application/octet-stream", + HttpHeaders.Authorization to "Bearer IGT:2:$token", + "Authority" to "www.threads.net", + HttpHeaders.Accept to "*/*", + HttpHeaders.AcceptLanguage to "en-US", + HttpHeaders.CacheControl to "no-cache", + HttpHeaders.Origin to "https://www.threads.net", + HttpHeaders.Pragma to "no-cache", + "Sec-Fetch-Site" to "same-origin", + "x-asbd-id" to "129477", + "x-fb-lsd" to "NjppQDEgONsU_1LCzrmp6q", + "x-ig-app-id" to "238260118697367", + HttpHeaders.Referrer to "https://www.threads.net/@$username", + "X_FB_PHOTO_WATERFALL_ID" to UUID.randomUUID().toString(), + "X-Entity-Type" to mimeType.toString(), + "Offset" to "0", + "X-Instagram-Rupload-Params" to ObjectParser.toJson(map), + "X-Entity-Name" to name, + "X-Entity-Length" to content.size.toString(), + HttpHeaders.ContentLength to content.size.toString(), + HttpHeaders.AcceptEncoding to "gzip" + ) + + return httpRequest.post( + "https://www.instagram.com/rupload_igphoto/$name", + headers = imageHeaders, + body = content, + ) + } + + suspend fun publish( + username: String, + deviceId: String, + userId: String, + token: String, + text: String, + image: ByteArray? = null, + ): HttpResponse { + val uploadId = System.currentTimeMillis().toString() + + if (image != null) { + val uploadImage = uploadImage(username, token, ContentType.Image.JPEG, uploadId, image) + require(uploadImage.status.value == 200) { "Failed to upload image: ${uploadImage.status}" } + } + + val map = mutableMapOf( + "upload_id" to uploadId, + "source_type" to "4", + "timezone_offset" to ZonedDateTime.now().offset.totalSeconds.toString(), + "device" to mapOf( + "manufacturer" to "OnePlus", + "model" to "ONEPLUS+A3010", + "os_version" to 25, + "os_release" to "7.1.1", + ), + "text_post_app_info" to mapOf( + "reply_control" to 0 + ), + "_uid" to userId, + "device_id" to deviceId, + "caption" to text, + ) + + if (image != null) { + map["scene_type"] = null + map["scene_capture_type"] = "" + } else map["publish_mode"] = "text_post" + + return httpRequest.post( + if (image != null) "$BASE_URL/api/v1/media/configure_text_post_app_feed/" else "$BASE_URL/api/v1/media/configure_text_only_post/", + headers = mapOf( + HttpHeaders.UserAgent to USER_AGENT, + HttpHeaders.ContentType to CONTENT_TYPE, + HttpHeaders.Authorization to "Bearer IGT:2:$token", + ), + body = "signed_body=SIGNATURE.${URLEncoder.encode(ObjectParser.toJson(map), StandardCharsets.UTF_8)}" + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/wrappers/ThreadsWrapper.kt b/src/main/kotlin/fr/shikkanime/wrappers/ThreadsWrapper.kt index 5067548e..9ddbc67c 100644 --- a/src/main/kotlin/fr/shikkanime/wrappers/ThreadsWrapper.kt +++ b/src/main/kotlin/fr/shikkanime/wrappers/ThreadsWrapper.kt @@ -1,284 +1,98 @@ package fr.shikkanime.wrappers -import fr.shikkanime.utils.EncryptionManager +import fr.shikkanime.utils.Constant import fr.shikkanime.utils.HttpRequest import fr.shikkanime.utils.ObjectParser -import fr.shikkanime.utils.ObjectParser.getAsString -import io.ktor.client.request.forms.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.utils.io.core.* -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.ByteArrayOutputStream +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode import java.net.URLEncoder -import java.nio.ByteBuffer -import java.nio.ByteOrder import java.nio.charset.StandardCharsets -import java.security.KeyFactory -import java.security.SecureRandom -import java.security.spec.X509EncodedKeySpec -import java.time.ZonedDateTime -import java.util.* -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec -import kotlin.math.abs -private const val BASE_URL = "https://i.instagram.com" -private const val LATEST_APP_VERSION = "291.0.0.31.111" -private const val EXPERIMENTS = - "ig_android_fci_onboarding_friend_search,ig_android_device_detection_info_upload,ig_android_account_linking_upsell_universe,ig_android_direct_main_tab_universe_v2,ig_android_allow_account_switch_once_media_upload_finish_universe,ig_android_sign_in_help_only_one_account_family_universe,ig_android_sms_retriever_backtest_universe,ig_android_direct_add_direct_to_android_native_photo_share_sheet,ig_android_spatial_account_switch_universe,ig_growth_android_profile_pic_prefill_with_fb_pic_2,ig_account_identity_logged_out_signals_global_holdout_universe,ig_android_prefill_main_account_username_on_login_screen_universe,ig_android_login_identifier_fuzzy_match,ig_android_mas_remove_close_friends_entrypoint,ig_android_shared_email_reg_universe,ig_android_video_render_codec_low_memory_gc,ig_android_custom_transitions_universe,ig_android_push_fcm,multiple_account_recovery_universe,ig_android_show_login_info_reminder_universe,ig_android_email_fuzzy_matching_universe,ig_android_one_tap_aymh_redesign_universe,ig_android_direct_send_like_from_notification,ig_android_suma_landing_page,ig_android_prefetch_debug_dialog,ig_android_smartlock_hints_universe,ig_android_black_out,ig_activation_global_discretionary_sms_holdout,ig_android_video_ffmpegutil_pts_fix,ig_android_multi_tap_login_new,ig_save_smartlock_universe,ig_android_caption_typeahead_fix_on_o_universe,ig_android_enable_keyboardlistener_redesign,ig_android_sign_in_password_visibility_universe,ig_android_nux_add_email_device,ig_android_direct_remove_view_mode_stickiness_universe,ig_android_hide_contacts_list_in_nux,ig_android_new_users_one_tap_holdout_universe,ig_android_ingestion_video_support_hevc_decoding,ig_android_mas_notification_badging_universe,ig_android_secondary_account_in_main_reg_flow_universe,ig_android_secondary_account_creation_universe,ig_android_account_recovery_auto_login,ig_android_pwd_encrytpion,ig_android_bottom_sheet_keyboard_leaks,ig_android_sim_info_upload,ig_android_mobile_http_flow_device_universe,ig_android_hide_fb_button_when_not_installed_universe,ig_android_account_linking_on_concurrent_user_session_infra_universe,ig_android_targeted_one_tap_upsell_universe,ig_android_gmail_oauth_in_reg,ig_android_account_linking_flow_shorten_universe,ig_android_vc_interop_use_test_igid_universe,ig_android_notification_unpack_universe,ig_android_registration_confirmation_code_universe,ig_android_device_based_country_verification,ig_android_log_suggested_users_cache_on_error,ig_android_reg_modularization_universe,ig_android_device_verification_separate_endpoint,ig_android_universe_noticiation_channels,ig_android_account_linking_universe,ig_android_hsite_prefill_new_carrier,ig_android_one_login_toast_universe,ig_android_retry_create_account_universe,ig_android_family_apps_user_values_provider_universe,ig_android_reg_nux_headers_cleanup_universe,ig_android_mas_ui_polish_universe,ig_android_device_info_foreground_reporting,ig_android_shortcuts_2019,ig_android_device_verification_fb_signup,ig_android_onetaplogin_optimization,ig_android_passwordless_account_password_creation_universe,ig_android_black_out_toggle_universe,ig_video_debug_overlay,ig_android_ask_for_permissions_on_reg,ig_assisted_login_universe,ig_android_security_intent_switchoff,ig_android_device_info_job_based_reporting,ig_android_add_account_button_in_profile_mas_universe,ig_android_add_dialog_when_delinking_from_child_account_universe,ig_android_passwordless_auth,ig_radio_button_universe_2,ig_android_direct_main_tab_account_switch,ig_android_recovery_one_tap_holdout_universe,ig_android_modularized_dynamic_nux_universe,ig_android_fb_account_linking_sampling_freq_universe,ig_android_fix_sms_read_lollipop,ig_android_access_flow_prefil" -private const val BLOKS_VERSION = "5f56efad68e1edec7801f630b5c122704ec5378adbee6609a448f105f34a9c73" -private const val CONTENT_TYPE = "application/x-www-form-urlencoded; charset=UTF-8" -private const val USER_AGENT = "Barcelona $LATEST_APP_VERSION Android" - -class ThreadsWrapper( - private val context: CoroutineDispatcher = Dispatchers.IO, -) { - private val httpRequest = HttpRequest() - private val secureRandom = SecureRandom() - - suspend fun qeSync(): HttpResponse { - val uuid = UUID.randomUUID().toString() - - return httpRequest.post( - "$BASE_URL/api/v1/qe/sync/", - headers = mapOf( - HttpHeaders.UserAgent to USER_AGENT, - HttpHeaders.ContentType to CONTENT_TYPE, - "Sec-Fetch-Site" to "same-origin", - "X-DEVICE-ID" to uuid, - ), - body = FormDataContent(Parameters.build { - append("id", uuid) - append("experiments", EXPERIMENTS) - }) - ) - } - - suspend fun encryptPassword(password: String): Map { - // https://github.com/instagram4j/instagram4j/blob/39635974c391e21a322ab3294275df99d7f75f84/src/main/java/com/github/instagram4j/instagram4j/utils/IGUtils.java#L176 - val randKey = ByteArray(32).also { secureRandom.nextBytes(it) } - val iv = ByteArray(12).also { secureRandom.nextBytes(it) } - val response = qeSync() - require(response.status.value == 200) { "Failed to get qeSync: ${response.status}" } - val headers = response.headers - val time = (System.currentTimeMillis() / 1000).toString() - - val passwordEncryptionKeyID = headers["ig-set-password-encryption-key-id"]!!.toInt() - val passwordEncryptionPubKey = headers["ig-set-password-encryption-pub-key"]!! - - // Encrypt random key - val decodedPubKey = String( - Base64.getDecoder().decode(passwordEncryptionPubKey), - StandardCharsets.UTF_8 - ).replace("-(.*)-|\n".toRegex(), "") - val rsaCipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING") // NOSONAR - rsaCipher.init( - Cipher.ENCRYPT_MODE, - KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(decodedPubKey))) - ) - val randKeyEncrypted = rsaCipher.doFinal(randKey) - - // Encrypt password - val aesGcmCipher = Cipher.getInstance("AES/GCM/NoPadding") - aesGcmCipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(randKey, "AES"), GCMParameterSpec(128, iv)) - aesGcmCipher.updateAAD(time.toByteArray()) - val passwordEncrypted = aesGcmCipher.doFinal(password.toByteArray()) - - // Write to final byte array - val out = ByteArrayOutputStream() - out.write(1) - out.write(Integer.valueOf(passwordEncryptionKeyID)) - - withContext(context) { - out.write(iv) - out.write( - ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putChar(randKeyEncrypted.size.toChar()).array() - ) - out.write(randKeyEncrypted) - out.write(Arrays.copyOfRange(passwordEncrypted, passwordEncrypted.size - 16, passwordEncrypted.size)) - out.write(Arrays.copyOfRange(passwordEncrypted, 0, passwordEncrypted.size - 16)) - } - - return mapOf( - "time" to time, - "password" to Base64.getEncoder().encodeToString(out.toByteArray()) - ) +object ThreadsWrapper { + enum class PostType { + TEXT, + IMAGE, + VIDEO, } - fun generateDeviceId(username: String, password: String): String { - val seed: String = EncryptionManager.toMD5(username + password) - val volatileSeed = "12345" - return "android-" + EncryptionManager.toMD5(seed + volatileSeed).substring(0, 16) - } + private const val AUTHORIZATION_URL = "https://www.threads.net" + private const val API_URL = "https://graph.threads.net" + private val httpRequest = HttpRequest() - suspend fun login(deviceId: String, username: String, password: String): Pair { - val encryptedPassword = encryptPassword(password) + fun getRedirectUri() = "${Constant.baseUrl}/api/threads".replace("http://", "https://") - val params = URLEncoder.encode( - ObjectParser.toJson( - mapOf( - "client_input_params" to mapOf( - "password" to "#PWD_INSTAGRAM:4:${encryptedPassword["time"]}:${encryptedPassword["password"]}", - "contact_point" to username, - "device_id" to deviceId, - ), - "server_params" to mapOf( - "credential_type" to "password", - "device_id" to deviceId, - ) - ) - ), StandardCharsets.UTF_8 - ) - - val bkClientContext = URLEncoder.encode( - ObjectParser.toJson( - mapOf( - "bloks_version" to BLOKS_VERSION, - "styles_id" to "instagram", - ) - ), StandardCharsets.UTF_8 - ) + fun getCode(appId: String) = "$AUTHORIZATION_URL/oauth/authorize?" + + "client_id=$appId&" + + "redirect_uri=${getRedirectUri()}&" + + "response_type=code&" + + "scope=threads_basic,threads_content_publish" + suspend fun getAccessToken(appId: String, appSecret: String, code: String): String { val response = httpRequest.post( - "$BASE_URL/api/v1/bloks/apps/com.bloks.www.bloks.caa.login.async.send_login_request/", - headers = mapOf( - HttpHeaders.UserAgent to USER_AGENT, - HttpHeaders.ContentType to CONTENT_TYPE, - "Response-Type" to "json", - ), - body = "params=$params&bk_client_context=$bkClientContext&bloks_versioning_id=$BLOKS_VERSION" + "$API_URL/oauth/access_token?" + + "client_id=$appId&" + + "client_secret=$appSecret&" + + "redirect_uri=${getRedirectUri()}&" + + "code=$code&" + + "grant_type=authorization_code", + headers = mapOf(HttpHeaders.ContentType to ContentType.Application.Json.toString()), ) - require(response.status.value == 200) { "Failed to login: ${response.status}" } - - val responseJson = ObjectParser.fromJson(response.bodyAsText()) - val rawBloks = responseJson.getAsJsonObject("layout").getAsJsonObject("bloks_payload").getAsJsonObject("tree") - .getAsJsonObject("㐟").getAsString("#") - val substring = - rawBloks!!.substring(rawBloks.indexOfFirst { it == '{' }, rawBloks.indexOfLast { it == '}' } + 1) - - val sToken = substring.split("Bearer IGT:2:")[1] - val token = sToken.substring(0, sToken.indexOf("\\\\\\\"")) + require(response.status == HttpStatusCode.OK) { "Failed to get token" } - val sUserID = substring.split("pk_id")[1].replace("\\\\\\\":\\\\\\\"", "\":\"") - val userID = sUserID.substring(3, sUserID.indexOf("\\\\\\\"")) - - return token to userID + val json = ObjectParser.fromJson(response.bodyAsText()) + return json["access_token"].asString } - private suspend fun uploadImage( - username: String, - token: String, - mimeType: ContentType, - uploadId: String, - content: ByteArray, - ): HttpResponse { - val name = "${uploadId}_0_${abs(secureRandom.nextInt())}" - - val map = mapOf( - "upload_id" to uploadId, - "media_type" to "1", - "sticker_burnin_params" to "[]", - "image_compression" to ObjectParser.toJson( - mapOf( - "lib_name" to "moz", - "lib_version" to "3.1.m", - "quality" to "80" - ) - ), - "xsharing_user_ids" to "[]", - "retry_context" to ObjectParser.toJson( - mapOf( - "num_step_auto_retry" to 0, - "num_reupload" to 0, - "num_step_manual_retry" to 0 - ) - ), - "IG-FB-Xpost-entry-point-v2" to "feed", + suspend fun getLongLivedAccessToken(appSecret: String, accessToken: String): String { + val response = httpRequest.get( + "$API_URL/access_token?" + + "client_secret=$appSecret&" + + "access_token=$accessToken&" + + "grant_type=th_exchange_token", ) - val imageHeaders = mapOf( - HttpHeaders.UserAgent to USER_AGENT, - HttpHeaders.ContentType to "application/octet-stream", - HttpHeaders.Authorization to "Bearer IGT:2:$token", - "Authority" to "www.threads.net", - HttpHeaders.Accept to "*/*", - HttpHeaders.AcceptLanguage to "en-US", - HttpHeaders.CacheControl to "no-cache", - HttpHeaders.Origin to "https://www.threads.net", - HttpHeaders.Pragma to "no-cache", - "Sec-Fetch-Site" to "same-origin", - "x-asbd-id" to "129477", - "x-fb-lsd" to "NjppQDEgONsU_1LCzrmp6q", - "x-ig-app-id" to "238260118697367", - HttpHeaders.Referrer to "https://www.threads.net/@$username", - "X_FB_PHOTO_WATERFALL_ID" to UUID.randomUUID().toString(), - "X-Entity-Type" to mimeType.toString(), - "Offset" to "0", - "X-Instagram-Rupload-Params" to ObjectParser.toJson(map), - "X-Entity-Name" to name, - "X-Entity-Length" to content.size.toString(), - HttpHeaders.ContentLength to content.size.toString(), - HttpHeaders.AcceptEncoding to "gzip" - ) + require(response.status == HttpStatusCode.OK) { "Failed to get long-lived token" } - return httpRequest.post( - "https://www.instagram.com/rupload_igphoto/$name", - headers = imageHeaders, - body = content, - ) + val json = ObjectParser.fromJson(response.bodyAsText()) + return json["access_token"].asString } - suspend fun publish( - username: String, - deviceId: String, - userId: String, - token: String, + suspend fun post( + accessToken: String, + postType: PostType, text: String, - image: ByteArray? = null, - ): HttpResponse { - val uploadId = System.currentTimeMillis().toString() + imageUrl: String? = null, + altText: String? = null, + replyToId: Long? = null + ): Long { + val parameters = mapOf( + "access_token" to accessToken, + "media_type" to postType.name, + "text" to URLEncoder.encode(text, StandardCharsets.UTF_8), + "image_url" to imageUrl, + "alt_text" to altText, + "reply_to_id" to replyToId, + ).filterValues { it != null }.map { (key, value) -> "$key=$value" }.joinToString("&") - if (image != null) { - val uploadImage = uploadImage(username, token, ContentType.Image.JPEG, uploadId, image) - require(uploadImage.status.value == 200) { "Failed to upload image: ${uploadImage.status}" } - } - - val map = mutableMapOf( - "upload_id" to uploadId, - "source_type" to "4", - "timezone_offset" to ZonedDateTime.now().offset.totalSeconds.toString(), - "device" to mapOf( - "manufacturer" to "OnePlus", - "model" to "ONEPLUS+A3010", - "os_version" to 25, - "os_release" to "7.1.1", - ), - "text_post_app_info" to mapOf( - "reply_control" to 0 - ), - "_uid" to userId, - "device_id" to deviceId, - "caption" to text, + val response = httpRequest.post( + "$API_URL/me/threads?$parameters", + headers = mapOf(HttpHeaders.ContentType to ContentType.Application.Json.toString()), ) - if (image != null) { - map["scene_type"] = null - map["scene_capture_type"] = "" - } else map["publish_mode"] = "text_post" + require(response.status == HttpStatusCode.OK) { "Failed to post" } + val creationId = ObjectParser.fromJson(response.bodyAsText())["id"].asString - return httpRequest.post( - if (image != null) "$BASE_URL/api/v1/media/configure_text_post_app_feed/" else "$BASE_URL/api/v1/media/configure_text_only_post/", - headers = mapOf( - HttpHeaders.UserAgent to USER_AGENT, - HttpHeaders.ContentType to CONTENT_TYPE, - HttpHeaders.Authorization to "Bearer IGT:2:$token", - ), - body = "signed_body=SIGNATURE.${URLEncoder.encode(ObjectParser.toJson(map), StandardCharsets.UTF_8)}" + val response2 = httpRequest.post( + "$API_URL/me/threads_publish?" + + "access_token=$accessToken&" + + "creation_id=$creationId", + headers = mapOf(HttpHeaders.ContentType to ContentType.Application.Json.toString()), ) + + require(response2.status == HttpStatusCode.OK) { "Failed to publish" } + return ObjectParser.fromJson(response2.bodyAsText())["id"].asLong } } \ No newline at end of file diff --git a/src/main/resources/db/changelog/2024/10/03-changelog.xml b/src/main/resources/db/changelog/2024/10/03-changelog.xml new file mode 100644 index 00000000..89566718 --- /dev/null +++ b/src/main/resources/db/changelog/2024/10/03-changelog.xml @@ -0,0 +1,118 @@ + + + + + + + + SELECT COUNT(*) + FROM config + WHERE property_key = 'threads_app_id' + + + + + + + + + + + + SELECT COUNT(*) + FROM config + WHERE property_key = 'threads_app_secret' + + + + + + + + + + + + SELECT COUNT(*) + FROM config + WHERE property_key = 'threads_access_token' + + + + + + + + + + + + SELECT COUNT(*) + FROM config + WHERE property_key = 'use_new_threads_wrapper' + + + + + + + + + + + + SELECT COUNT(*) + FROM config + WHERE property_key = 'bsky_message' + + + + + property_key = 'bsky_message' + + + + + + SELECT COUNT(*) + FROM config + WHERE property_key = 'bsky_second_message' + + + + + + + + + + + + SELECT COUNT(*) + FROM config + WHERE property_key = 'threads_message' + + + + + property_key = 'threads_message' + + + + + + SELECT COUNT(*) + FROM config + WHERE property_key = 'threads_second_message' + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.xml b/src/main/resources/db/changelog/db.changelog-master.xml index f517c5dc..db9cfd12 100644 --- a/src/main/resources/db/changelog/db.changelog-master.xml +++ b/src/main/resources/db/changelog/db.changelog-master.xml @@ -69,4 +69,5 @@ + \ No newline at end of file diff --git a/src/main/resources/templates/_freemarker_implicit.ftl b/src/main/resources/templates/_freemarker_implicit.ftl index eca80c5f..f5ccf6cb 100644 --- a/src/main/resources/templates/_freemarker_implicit.ftl +++ b/src/main/resources/templates/_freemarker_implicit.ftl @@ -24,6 +24,8 @@ [#-- @ftlvariable name="season" type="fr.shikkanime.dtos.SeasonDto" --] [#-- @ftlvariable name="previousWeek" type="java.lang.String" --] [#-- @ftlvariable name="nextWeek" type="java.lang.String" --] +[#-- @ftlvariable name="askCodeUrl" type="java.lang.String" --] +[#-- @ftlvariable name="success" type="java.lang.Integer" --] [#-- @ftlvariable name="baseUrl" type="java.lang.String" --] [#-- @ftlvariable name="apiUrl" type="java.lang.String" --] diff --git a/src/main/resources/templates/admin/threads.ftl b/src/main/resources/templates/admin/threads.ftl new file mode 100644 index 00000000..a07a90bf --- /dev/null +++ b/src/main/resources/templates/admin/threads.ftl @@ -0,0 +1,23 @@ +<#import "_navigation.ftl" as navigation /> + +<@navigation.display> +
+
+ Ask Code + + <#if success?? && success == 1> + + +
+
+ +
+
+ + + +
+
+ \ No newline at end of file diff --git a/src/test/kotlin/fr/shikkanime/wrappers/ThreadsWrapperTest.kt b/src/test/kotlin/fr/shikkanime/wrappers/OldThreadsWrapperTest.kt similarity index 64% rename from src/test/kotlin/fr/shikkanime/wrappers/ThreadsWrapperTest.kt rename to src/test/kotlin/fr/shikkanime/wrappers/OldThreadsWrapperTest.kt index 10b216fe..5a68f2bc 100644 --- a/src/test/kotlin/fr/shikkanime/wrappers/ThreadsWrapperTest.kt +++ b/src/test/kotlin/fr/shikkanime/wrappers/OldThreadsWrapperTest.kt @@ -6,12 +6,12 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Test -class ThreadsWrapperTest { - private val threadsWrapper = ThreadsWrapper() +class OldThreadsWrapperTest { + private val oldThreadsWrapper = OldThreadsWrapper() @Test fun qeSync() { - val response = runBlocking { threadsWrapper.qeSync() } + val response = runBlocking { oldThreadsWrapper.qeSync() } assertEquals(HttpStatusCode.OK, response.status) } @@ -19,13 +19,13 @@ class ThreadsWrapperTest { fun generateDeviceId() { val username = "Hello" val password = "World!" - assertEquals("android-6f36600bd3a8126c", threadsWrapper.generateDeviceId(username, password)) + assertEquals("android-6f36600bd3a8126c", oldThreadsWrapper.generateDeviceId(username, password)) } @Test fun encryptPassword() { val password = "World!" - val response = runBlocking { threadsWrapper.encryptPassword(password) } + val response = runBlocking { oldThreadsWrapper.encryptPassword(password) } assertNotNull(response) assertNotNull(response["time"]) assertNotNull(response["password"])