347 lines
10 KiB
Go
347 lines
10 KiB
Go
|
|
package usecase
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
"haixun-backend/internal/library/clock"
|
|||
|
|
app "haixun-backend/internal/library/errors"
|
|||
|
|
"haixun-backend/internal/library/errors/code"
|
|||
|
|
libthreads "haixun-backend/internal/library/threadsapi"
|
|||
|
|
"haixun-backend/internal/model/mention_inbox/domain/entity"
|
|||
|
|
domrepo "haixun-backend/internal/model/mention_inbox/domain/repository"
|
|||
|
|
domusecase "haixun-backend/internal/model/mention_inbox/domain/usecase"
|
|||
|
|
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
type mentionInboxUseCase struct {
|
|||
|
|
repo domrepo.Repository
|
|||
|
|
threadsAccounts threadsaccountdomain.UseCase
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func NewUseCase(repo domrepo.Repository, threadsAccounts threadsaccountdomain.UseCase) domusecase.UseCase {
|
|||
|
|
return &mentionInboxUseCase{repo: repo, threadsAccounts: threadsAccounts}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (u *mentionInboxUseCase) SyncFromAPI(ctx context.Context, req domusecase.SyncRequest) (*domusecase.SyncResult, error) {
|
|||
|
|
tenantID := strings.TrimSpace(req.TenantID)
|
|||
|
|
ownerUID := strings.TrimSpace(req.OwnerUID)
|
|||
|
|
accountID := strings.TrimSpace(req.AccountID)
|
|||
|
|
if tenantID == "" || ownerUID == "" {
|
|||
|
|
return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
|
|||
|
|
}
|
|||
|
|
if accountID == "" {
|
|||
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
client := libthreads.NewClient(token)
|
|||
|
|
if !client.Enabled() {
|
|||
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
userID := strings.TrimSpace(account.ThreadsUserID)
|
|||
|
|
if userID == "" {
|
|||
|
|
if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil {
|
|||
|
|
userID = profile.ID.String()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if userID == "" {
|
|||
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
perPage := req.Limit
|
|||
|
|
if perPage <= 0 {
|
|||
|
|
perPage = 25
|
|||
|
|
}
|
|||
|
|
if perPage > 100 {
|
|||
|
|
perPage = 100
|
|||
|
|
}
|
|||
|
|
maxPages := req.MaxPages
|
|||
|
|
if maxPages <= 0 {
|
|||
|
|
maxPages = 4
|
|||
|
|
}
|
|||
|
|
if maxPages > 20 {
|
|||
|
|
maxPages = 20
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
now := clock.NowUnixNano()
|
|||
|
|
synced := 0
|
|||
|
|
ready := 0
|
|||
|
|
after := ""
|
|||
|
|
for page := 0; page < maxPages; page++ {
|
|||
|
|
mentions, nextAfter, err := client.UserMentionsPage(ctx, userID, perPage, after)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
for _, hit := range mentions {
|
|||
|
|
item := u.buildMentionEntity(tenantID, ownerUID, accountID, hit, now)
|
|||
|
|
u.fillContext(ctx, client, hit, item)
|
|||
|
|
u.primeReplyTarget(ctx, client, hit, item)
|
|||
|
|
if _, err := u.repo.Upsert(ctx, item); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
synced++
|
|||
|
|
if item.ReplyReady {
|
|||
|
|
ready++
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if nextAfter == "" || len(mentions) == 0 {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
after = nextAfter
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
total, err := u.repo.CountByAccount(ctx, tenantID, ownerUID, accountID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &domusecase.SyncResult{
|
|||
|
|
Synced: synced,
|
|||
|
|
Ready: ready,
|
|||
|
|
Total: total,
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (u *mentionInboxUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) {
|
|||
|
|
tenantID := strings.TrimSpace(req.TenantID)
|
|||
|
|
ownerUID := strings.TrimSpace(req.OwnerUID)
|
|||
|
|
accountID := strings.TrimSpace(req.AccountID)
|
|||
|
|
if tenantID == "" || ownerUID == "" {
|
|||
|
|
return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
|
|||
|
|
}
|
|||
|
|
if accountID == "" {
|
|||
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
|
|||
|
|
}
|
|||
|
|
if _, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
result, err := u.repo.ListByAccount(ctx, tenantID, ownerUID, accountID, req.Page, req.PageSize)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
page, pageSize := normalizeListPage(req.Page, req.PageSize)
|
|||
|
|
return &domusecase.ListResult{
|
|||
|
|
List: toSummaries(result.Items),
|
|||
|
|
Pagination: domusecase.Pagination{
|
|||
|
|
Total: result.Total,
|
|||
|
|
ReadyTotal: result.ReadyTotal,
|
|||
|
|
Page: int64(page),
|
|||
|
|
PageSize: int64(pageSize),
|
|||
|
|
TotalPages: mentionTotalPages(result.Total, pageSize),
|
|||
|
|
NewestSyncedAt: result.NewestSyncedAt,
|
|||
|
|
},
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func normalizeListPage(page, pageSize int) (int, int) {
|
|||
|
|
if page <= 0 {
|
|||
|
|
page = 1
|
|||
|
|
}
|
|||
|
|
if pageSize <= 0 {
|
|||
|
|
pageSize = 10
|
|||
|
|
}
|
|||
|
|
if pageSize > 50 {
|
|||
|
|
pageSize = 50
|
|||
|
|
}
|
|||
|
|
return page, pageSize
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func mentionTotalPages(total int64, pageSize int) int64 {
|
|||
|
|
if total <= 0 {
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
return (total + int64(pageSize) - 1) / int64(pageSize)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (u *mentionInboxUseCase) PublishReply(ctx context.Context, req domusecase.PublishReplyRequest) (*domusecase.PublishReplyResult, error) {
|
|||
|
|
tenantID := strings.TrimSpace(req.TenantID)
|
|||
|
|
ownerUID := strings.TrimSpace(req.OwnerUID)
|
|||
|
|
accountID := strings.TrimSpace(req.AccountID)
|
|||
|
|
mediaID := strings.TrimSpace(req.MediaID)
|
|||
|
|
text := strings.TrimSpace(req.Text)
|
|||
|
|
if tenantID == "" || ownerUID == "" {
|
|||
|
|
return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
|
|||
|
|
}
|
|||
|
|
if accountID == "" || mediaID == "" {
|
|||
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required")
|
|||
|
|
}
|
|||
|
|
if text == "" {
|
|||
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if !item.ReplyReady {
|
|||
|
|
msg := strings.TrimSpace(item.ReplyReadyMsg)
|
|||
|
|
if msg == "" {
|
|||
|
|
msg = "此提及尚未完成回覆註冊,請先重新同步提及清單"
|
|||
|
|
}
|
|||
|
|
return nil, app.For(code.ThreadsAccount).InputInvalidFormat(msg)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
userID := strings.TrimSpace(account.ThreadsUserID)
|
|||
|
|
if userID == "" {
|
|||
|
|
if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil {
|
|||
|
|
userID = profile.ID.String()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if userID == "" {
|
|||
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result, err := libthreads.PublishReply(ctx, libthreads.PublishReplyInput{
|
|||
|
|
ThreadsUserID: userID,
|
|||
|
|
AccessToken: token,
|
|||
|
|
ReplyToID: mediaID,
|
|||
|
|
Text: text,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
out := &domusecase.PublishReplyResult{}
|
|||
|
|
if result != nil {
|
|||
|
|
out.MediaID = strings.TrimSpace(result.MediaID)
|
|||
|
|
out.Permalink = strings.TrimSpace(result.Permalink)
|
|||
|
|
}
|
|||
|
|
return out, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (u *mentionInboxUseCase) buildMentionEntity(
|
|||
|
|
tenantID, ownerUID, accountID string,
|
|||
|
|
hit libthreads.MentionItem,
|
|||
|
|
now int64,
|
|||
|
|
) *entity.Mention {
|
|||
|
|
mediaID := strings.TrimSpace(hit.ID)
|
|||
|
|
return &entity.Mention{
|
|||
|
|
ID: entity.DocID(accountID, mediaID),
|
|||
|
|
TenantID: tenantID,
|
|||
|
|
OwnerUID: ownerUID,
|
|||
|
|
AccountID: accountID,
|
|||
|
|
MediaID: mediaID,
|
|||
|
|
AuthorUsername: strings.TrimSpace(hit.Username),
|
|||
|
|
Text: strings.TrimSpace(hit.Text),
|
|||
|
|
Permalink: strings.TrimSpace(hit.Permalink),
|
|||
|
|
Shortcode: shortcodeFromPermalink(hit.Permalink),
|
|||
|
|
Timestamp: strings.TrimSpace(hit.Timestamp),
|
|||
|
|
MediaType: strings.TrimSpace(hit.MediaType),
|
|||
|
|
IsReply: hit.IsReply,
|
|||
|
|
IsQuotePost: hit.IsQuotePost,
|
|||
|
|
HasReplies: hit.HasReplies,
|
|||
|
|
RootPostID: strings.TrimSpace(hit.RootPostID),
|
|||
|
|
ParentID: strings.TrimSpace(hit.ParentID),
|
|||
|
|
SyncedAt: now,
|
|||
|
|
UpdateAt: now,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (u *mentionInboxUseCase) fillContext(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) {
|
|||
|
|
mentionText := strings.TrimSpace(hit.Text)
|
|||
|
|
if !hit.IsReply && !hit.IsQuotePost {
|
|||
|
|
item.ThreadPostText = mentionText
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
rootID := strings.TrimSpace(hit.RootPostID)
|
|||
|
|
if rootID == "" {
|
|||
|
|
rootID = strings.TrimSpace(hit.ID)
|
|||
|
|
}
|
|||
|
|
if rootID != "" {
|
|||
|
|
if detail, err := client.GetMediaDetail(ctx, rootID); err == nil && detail != nil {
|
|||
|
|
item.ThreadPostText = strings.TrimSpace(detail.Text)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if item.ThreadPostText == "" {
|
|||
|
|
item.ThreadPostText = mentionText
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
parentID := strings.TrimSpace(hit.ParentID)
|
|||
|
|
mediaID := strings.TrimSpace(hit.ID)
|
|||
|
|
if hit.IsReply && parentID != "" && parentID != mediaID && parentID != rootID {
|
|||
|
|
if detail, err := client.GetMediaDetail(ctx, parentID); err == nil && detail != nil {
|
|||
|
|
item.ParentReplyText = strings.TrimSpace(detail.Text)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (u *mentionInboxUseCase) primeReplyTarget(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) {
|
|||
|
|
permalink := strings.TrimSpace(hit.Permalink)
|
|||
|
|
mediaID := strings.TrimSpace(hit.ID)
|
|||
|
|
if permalink == "" {
|
|||
|
|
item.ReplyReady = false
|
|||
|
|
item.ReplyReadyMsg = "缺少 permalink,無法註冊回覆目標"
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{
|
|||
|
|
ExternalID: mediaID,
|
|||
|
|
Permalink: permalink,
|
|||
|
|
HintText: strings.TrimSpace(hit.Text),
|
|||
|
|
}, mediaID); err != nil {
|
|||
|
|
item.ReplyReady = false
|
|||
|
|
item.ReplyReadyMsg = err.Error()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
item.ReplyReady = true
|
|||
|
|
item.ReplyReadyMsg = ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func shortcodeFromPermalink(permalink string) string {
|
|||
|
|
permalink = strings.Split(strings.TrimSpace(permalink), "?")[0]
|
|||
|
|
permalink = strings.Split(permalink, "#")[0]
|
|||
|
|
parts := strings.Split(permalink, "/post/")
|
|||
|
|
if len(parts) < 2 {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
return strings.TrimSpace(parts[len(parts)-1])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func toSummary(item *entity.Mention) domusecase.MentionSummary {
|
|||
|
|
if item == nil {
|
|||
|
|
return domusecase.MentionSummary{}
|
|||
|
|
}
|
|||
|
|
return domusecase.MentionSummary{
|
|||
|
|
MediaID: item.MediaID,
|
|||
|
|
AuthorUsername: item.AuthorUsername,
|
|||
|
|
Text: item.Text,
|
|||
|
|
Permalink: item.Permalink,
|
|||
|
|
Shortcode: item.Shortcode,
|
|||
|
|
Timestamp: item.Timestamp,
|
|||
|
|
MediaType: item.MediaType,
|
|||
|
|
IsReply: item.IsReply,
|
|||
|
|
IsQuotePost: item.IsQuotePost,
|
|||
|
|
HasReplies: item.HasReplies,
|
|||
|
|
RootPostID: item.RootPostID,
|
|||
|
|
ParentID: item.ParentID,
|
|||
|
|
ThreadPostText: item.ThreadPostText,
|
|||
|
|
ParentReplyText: item.ParentReplyText,
|
|||
|
|
ReplyReady: item.ReplyReady,
|
|||
|
|
ReplyReadyMsg: item.ReplyReadyMsg,
|
|||
|
|
SyncedAt: item.SyncedAt,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func toSummaries(items []entity.Mention) []domusecase.MentionSummary {
|
|||
|
|
out := make([]domusecase.MentionSummary, 0, len(items))
|
|||
|
|
for i := range items {
|
|||
|
|
out = append(out, toSummary(&items[i]))
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|