add better inline result preview for twitter and weibo

- cha Twitter URL reformatted added the description
- cha SocialContent and ShareToolSocialContent
  - add support for SocialContent to set the query result title and description
  - cha weibo and twitter's inline result shows
    - add the description, with original url and preview mode.
    - add the title to show a brief of the content
      - 35 chars max currently
      - if there are only medias with no text content, the author name will be used instead.
This commit is contained in:
A.C.Sukazyo Eyre 2024-08-24 20:34:26 +08:00
parent 0b560180f4
commit 409ad0f517
Signed by: Eyre_S
GPG Key ID: C17CE40291207874
10 changed files with 77 additions and 24 deletions

View File

@ -28,6 +28,8 @@ class MornyOnInlineQuery (using queryManager: MornyQueries) (using coeur: MornyC
if (r isPersonal) isPersonal = true if (r isPersonal) isPersonal = true
resultAnswers += r.result resultAnswers += r.result
} }
cacheTime = 1
logger debug "Inline Query remote caches is DISABLED, you may received duplicate queries logs."
if (results isEmpty) return; if (results isEmpty) return;

View File

@ -66,11 +66,11 @@ object OnGetSocial {
case Left(texts) => case Left(texts) =>
weibo.guessWeiboStatusUrl(texts.trim) weibo.guessWeiboStatusUrl(texts.trim)
case Right(url) => case Right(url) =>
weibo.parseWeiboStatusUrl(url.trim).toList weibo.parseWeiboStatusUrl(url.trim).map(url -> _).toList
}.map(f => { }.map { (url, status) =>
succeed += 1 succeed += 1
tryFetchSocialOfWeibo(f) tryFetchSocialOfWeibo(status, url)
}) }
{ {
val bilibiliVideos: List[BiliVideoId] = text match val bilibiliVideos: List[BiliVideoId] = text match
@ -124,13 +124,13 @@ object OnGetSocial {
"Error on requesting FixTweet API\n" + exceptionLog(e) "Error on requesting FixTweet API\n" + exceptionLog(e)
coeur.daemons.reporter.exception(e, "Error on requesting FixTweet API") coeur.daemons.reporter.exception(e, "Error on requesting FixTweet API")
private def tryFetchSocialOfWeibo (url: weibo.StatusUrlInfo)(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur) = private def tryFetchSocialOfWeibo (url: weibo.StatusUrlInfo, rawUrl: String)(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur) =
import io.circe.{DecodingFailure, ParsingFailure} import io.circe.{DecodingFailure, ParsingFailure}
import sttp.client3.{HttpError, SttpClientException} import sttp.client3.{HttpError, SttpClientException}
import weibo.MApi import weibo.MApi
try { try {
val api = MApi.Fetch.statuses_show(url.id) val api = MApi.Fetch.statuses_show(url.id)
SocialWeiboParser.parseMStatus(api).outputToTelegram SocialWeiboParser.parseMStatus(api)(rawUrl).outputToTelegram
} catch } catch
case e: HttpError[?] => case e: HttpError[?] =>
coeur.account exec SendMessage( coeur.account exec SendMessage(

View File

@ -52,17 +52,17 @@ class ShareToolSocialContent extends ITelegramQuery {
SocialTwitterParser.parseFXTweet(FXApi.Fetch.status(Some(tweet.statusPath), tweet.statusId)) SocialTwitterParser.parseFXTweet(FXApi.Fetch.status(Some(tweet.statusPath), tweet.statusId))
.genInlineQueryResults(using .genInlineQueryResults(using
"morny/share/tweet/content", tweet.statusId, "morny/share/tweet/content", tweet.statusId,
"Twitter Tweet Content" "[Twitter]"
) )
} }
} }
private def weiboStatus (query: String): List[InlineQueryUnit[_]] = { private def weiboStatus (query: String): List[InlineQueryUnit[_]] = {
weibo.guessWeiboStatusUrl(query).flatMap { status => weibo.guessWeiboStatusUrl(query).flatMap { (url, status) =>
SocialWeiboParser.parseMStatus(MApi.Fetch.statuses_show(status.id)) SocialWeiboParser.parseMStatus(MApi.Fetch.statuses_show(status.id))(url)
.genInlineQueryResults(using .genInlineQueryResults(using
"morny/share/weibo/status/content", status.id, "morny/share/weibo/status/content", status.id,
"Weibo Content" "[Weibo]"
) )
} }
} }

View File

@ -29,11 +29,15 @@ class ShareToolTwitter extends ITelegramQuery {
getQueryTweetId(ID_PREFIX_FX, tweet), getQueryTweetId(ID_PREFIX_FX, tweet),
getTweetName(TITLE_FX, tweet), getTweetName(TITLE_FX, tweet),
s"https://fxtwitter.com/${tweet.statusPath}" s"https://fxtwitter.com/${tweet.statusPath}"
).description(
"URL only, and Fix-Tweet web preview available."
)), )),
InlineQueryUnit(InlineQueryResultArticle( InlineQueryUnit(InlineQueryResultArticle(
getQueryTweetId(ID_PREFIX_VX, tweet), getQueryTweetId(ID_PREFIX_VX, tweet),
getTweetName(TITLE_VX, tweet), getTweetName(TITLE_VX, tweet),
s"https://vxtwitter.com/${tweet.statusPath}" s"https://vxtwitter.com/${tweet.statusPath}"
).description(
"URL only, and VxTwitter web preview available."
)) ))
) )
) )

View File

@ -32,7 +32,7 @@ class ShareToolXhs extends ITelegramQuery {
getTitle(xhsLink), getTitle(xhsLink),
xhsLink.link xhsLink.link
).description( ).description(
"URL only." + (if maybeFromShare.nonEmpty then s" from $maybeFromShare" else "") "URL only." + (if maybeFromShare.nonEmpty then s" from ${maybeFromShare.get}" else "")
)) ))
) )

View File

@ -30,11 +30,13 @@ import com.pengrad.telegrambot.request.{SendMediaGroup, SendMessage}
* parser/formatter implementation. * parser/formatter implementation.
*/ */
case class SocialContent ( case class SocialContent (
title: String,
description: String,
text_html: String, text_html: String,
text_withPicPlaceholder: String, text_withPicPlaceholder: String,
medias: List[SocialMedia], medias: List[SocialMedia],
medias_mosaic: Option[SocialMedia] = None, medias_mosaic: Option[SocialMedia] = None,
thumbnail: Option[SocialMedia] = None thumbnail: Option[SocialMedia] = None,
) { ) {
def thumbnailOrElse[T] (orElse: T): String | T = def thumbnailOrElse[T] (orElse: T): String | T =
@ -61,31 +63,55 @@ case class SocialContent (
def genInlineQueryResults (using id_head: String, id_param: Any, name: String): List[InlineQueryUnit[?]] = { def genInlineQueryResults (using id_head: String, id_param: Any, name: String): List[InlineQueryUnit[?]] = {
( (
if (medias_mosaic nonEmpty) && (medias_mosaic.get.t == Photo) && medias_mosaic.get.isInstanceOf[SocialMediaWithUrl] then if (medias_mosaic nonEmpty) && (medias_mosaic.get.t == Photo) && medias_mosaic.get.isInstanceOf[SocialMediaWithUrl] then
// It has multi medias, and the mosaic version is provided.
InlineQueryUnit(InlineQueryResultPhoto( InlineQueryUnit(InlineQueryResultPhoto(
s"[$id_head/photo/mosaic]$id_param", s"[$id_head/photo/mosaic]$id_param",
medias_mosaic.get.asInstanceOf[SocialMediaWithUrl].url, medias_mosaic.get.asInstanceOf[SocialMediaWithUrl].url,
thumbnailOrElse(medias_mosaic.get.asInstanceOf[SocialMediaWithUrl].url) thumbnailOrElse(medias_mosaic.get.asInstanceOf[SocialMediaWithUrl].url)
).title(s"$name").caption(text_html).parseMode(ParseMode.HTML)) :: Nil ).title(
s"$name $title"
).description(
s"Pictures are combined. $description"
).caption(
text_html
).parseMode(ParseMode.HTML)) :: Nil
else if (medias nonEmpty) && (medias.head.t == Photo) then else if (medias nonEmpty) && (medias.head.t == Photo) then
val media = medias.head val media = medias.head
media match media match
case media_url: SocialMediaWithUrl => case media_url: SocialMediaWithUrl =>
// the medias is provided, and the first one is in URL format.
// it may still contain multiple medias.
// although in only two implementations, the Twitter implementation will always give a mosaic
// pic; and the Weibo implementation never uses URL formatted medias.
InlineQueryUnit(InlineQueryResultPhoto( InlineQueryUnit(InlineQueryResultPhoto(
s"[$id_head/photo/0]$id_param", s"[$id_head/photo/0]$id_param",
media_url.url, media_url.url,
thumbnailOrElse(media_url.url) thumbnailOrElse(media_url.url)
).title(s"$name").caption(text_html).parseMode(ParseMode.HTML)) :: Nil ).title(
s"$name $title"
).description(
s"Pic 1. $description"
).caption(
text_html
).parseMode(ParseMode.HTML)) :: Nil
case _ => case _ =>
// the medias are provided but are not in URL format.
// in this case, the plain text version will be used.
InlineQueryUnit(InlineQueryResultArticle( InlineQueryUnit(InlineQueryResultArticle(
s"[$id_head/text_only]$id_param", s"[$id_head/text_only]$id_param",
s"$name (text only)", s"$name $title",
InputTextMessageContent(text_withPicPlaceholder).parseMode(ParseMode.HTML) InputTextMessageContent(text_withPicPlaceholder).parseMode(ParseMode.HTML)
).description(
s"Plain text only. $description"
)) :: Nil )) :: Nil
else else
// There are never any medias.
InlineQueryUnit(InlineQueryResultArticle( InlineQueryUnit(InlineQueryResultArticle(
s"[$id_head/text]$id_param", s"[$id_head/text]$id_param",
s"$name", s"$name $title",
InputTextMessageContent(text_html).parseMode(ParseMode.HTML) InputTextMessageContent(text_html).parseMode(ParseMode.HTML)
).description(
description
)) :: Nil )) :: Nil
) ::: Nil ) ::: Nil
} }

View File

@ -5,6 +5,8 @@ import cc.sukazyo.cono.morny.data.social.SocialContent.SocialMediaType.{Photo, V
import cc.sukazyo.cono.morny.extra.twitter.{FXApi, FXTweet} import cc.sukazyo.cono.morny.extra.twitter.{FXApi, FXTweet}
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
import cc.sukazyo.cono.morny.util.StringEnsure.ensureNotExceed
object SocialTwitterParser { object SocialTwitterParser {
def parseFXTweet_forMediaPlaceholderInContent (tweet: FXTweet): String = def parseFXTweet_forMediaPlaceholderInContent (tweet: FXTweet): String =
@ -21,7 +23,7 @@ object SocialTwitterParser {
// language=html // language=html
s"""❌ Fix-Tweet <code>${api.code}</code> s"""❌ Fix-Tweet <code>${api.code}</code>
|<i>${h(api.message)}</i>""".stripMargin |<i>${h(api.message)}</i>""".stripMargin
SocialContent(content, content, Nil) SocialContent("ERROR", "ERROR", content, content, Nil)
case Some(tweet) => case Some(tweet) =>
val content: String = val content: String =
// language=html // language=html
@ -39,9 +41,11 @@ object SocialTwitterParser {
| |
|<i>💬${tweet.replies} 🔗${tweet.retweets} ${tweet.likes}</i> |<i>💬${tweet.replies} 🔗${tweet.retweets} ${tweet.likes}</i>
|<i><a href="${tweet.url}">${h(tweet.created_at)}</a></i>""".stripMargin |<i><a href="${tweet.url}">${h(tweet.created_at)}</a></i>""".stripMargin
val title: String = tweet.text.ensureNotExceed(35)
val description: String = tweet.url
tweet.media match tweet.media match
case None => case None =>
SocialContent(content, content_withMediasPlaceholder, Nil) SocialContent(title, description, content, content_withMediasPlaceholder, Nil)
case Some(media) => case Some(media) =>
val mediaGroup: List[SocialMedia] = val mediaGroup: List[SocialMedia] =
( (
@ -60,7 +64,11 @@ object SocialTwitterParser {
val mediaMosaic = media.mosaic match val mediaMosaic = media.mosaic match
case Some(mosaic) => Some(SocialMediaWithUrl(mosaic.formats.jpeg)(Photo)) case Some(mosaic) => Some(SocialMediaWithUrl(mosaic.formats.jpeg)(Photo))
case None => None case None => None
SocialContent(content, content_withMediasPlaceholder, mediaGroup, mediaMosaic, thumbnail) SocialContent(
if title.nonEmpty then title else
s"from ${tweet.author.name}",
description, content, content_withMediasPlaceholder, mediaGroup, mediaMosaic, thumbnail
)
} }
} }

View File

@ -7,6 +7,8 @@ import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.{cleanupH
import io.circe.{DecodingFailure, ParsingFailure} import io.circe.{DecodingFailure, ParsingFailure}
import sttp.client3.{HttpError, SttpClientException} import sttp.client3.{HttpError, SttpClientException}
import cc.sukazyo.cono.morny.util.StringEnsure.ensureNotExceed
object SocialWeiboParser { object SocialWeiboParser {
def parseMStatus_forPicPreview (status: MStatus): String = def parseMStatus_forPicPreview (status: MStatus): String =
@ -24,7 +26,7 @@ object SocialWeiboParser {
case None => "" case None => ""
@throws[HttpError[?] | SttpClientException | ParsingFailure | DecodingFailure] @throws[HttpError[?] | SttpClientException | ParsingFailure | DecodingFailure]
def parseMStatus (api: MApi[MStatus]): SocialContent = { def parseMStatus (api: MApi[MStatus])(originUrl: String): SocialContent = {
val content = val content =
// language=html // language=html
s"""🔸<b><a href="${api.data.user.profile_url}">${h(api.data.user.screen_name)}</a></b> s"""🔸<b><a href="${api.data.user.profile_url}">${h(api.data.user.screen_name)}</a></b>
@ -39,12 +41,18 @@ object SocialWeiboParser {
|${ch(api.data.text)}${parseMStatus_forPicPreview(api.data)} |${ch(api.data.text)}${parseMStatus_forPicPreview(api.data)}
|${parseMStatus_forRetweeted(api.data)} |${parseMStatus_forRetweeted(api.data)}
|<i><a href="${genWeiboStatusUrl(StatusUrlInfo(api.data.user.id.toString, api.data.id))}">${h(api.data.created_at)}</a></i>""".stripMargin |<i><a href="${genWeiboStatusUrl(StatusUrlInfo(api.data.user.id.toString, api.data.id))}">${h(api.data.created_at)}</a></i>""".stripMargin
val title = api.data.text.ensureNotExceed(35)
val description: String = originUrl
api.data.pics match api.data.pics match
case None => case None =>
SocialContent(content, content_withPicPlaceholder, Nil) SocialContent(title, description, content, content_withPicPlaceholder, Nil)
case Some(pics) => case Some(pics) =>
val mediaGroup = pics.map(f => SocialMediaWithBytesData(MApi.Fetch.pic(f.large.url))(Photo)) val mediaGroup = pics.map(f => SocialMediaWithBytesData(MApi.Fetch.pic(f.large.url))(Photo))
SocialContent(content, content_withPicPlaceholder, mediaGroup) SocialContent(
if title.nonEmpty then title else
s"from ${api.data.user.screen_name}",
description, content, content_withPicPlaceholder, mediaGroup
)
} }
} }

View File

@ -30,9 +30,9 @@ package object weibo {
case REGEX_WEIBO_STATUS_URL(_, uid, id, _) => Some(StatusUrlInfo(uid, id)) case REGEX_WEIBO_STATUS_URL(_, uid, id, _) => Some(StatusUrlInfo(uid, id))
case _ => None case _ => None
def guessWeiboStatusUrl (text: String): List[StatusUrlInfo] = def guessWeiboStatusUrl (text: String): List[(String, StatusUrlInfo)] =
REGEX_WEIBO_STATUS_URL.findAllMatchIn(text).map(matches => { REGEX_WEIBO_STATUS_URL.findAllMatchIn(text).map(matches => {
StatusUrlInfo(matches.group(2), matches.group(3)) matches.group(0) -> StatusUrlInfo(matches.group(2), matches.group(3))
}).toList }).toList
def genWeiboStatusUrl (url: StatusUrlInfo): String = def genWeiboStatusUrl (url: StatusUrlInfo): String =

View File

@ -12,6 +12,11 @@ object StringEnsure {
} else str } else str
} }
def ensureNotExceed (size: Int, ellipsis: String = "..."): String = {
if (str.length <= size) str
else str.take(size) + ellipsis
}
def deSensitive (keepStart: Int = 2, keepEnd: Int = 4, sensitive_cover: Char = '*'): String = def deSensitive (keepStart: Int = 2, keepEnd: Int = 4, sensitive_cover: Char = '*'): String =
(str take keepStart) + (sensitive_cover.toString*(str.length-keepStart-keepEnd)) + (str takeRight keepEnd) (str take keepStart) + (sensitive_cover.toString*(str.length-keepStart-keepEnd)) + (str takeRight keepEnd)