diff --git a/apps/backend/generate/api/auth.api b/apps/backend/generate/api/auth.api index 063398c..bb387e0 100644 --- a/apps/backend/generate/api/auth.api +++ b/apps/backend/generate/api/auth.api @@ -57,6 +57,8 @@ type ( PostCode string `json:"post_code,optional"` PreferredLanguage string `json:"preferred_language,optional"` Currency string `json:"currency,optional"` + // skipped | completed;只寫一次,已結束後再送會被忽略 + OnboardingStatus string `json:"onboarding_status,optional"` CurrentPassword string `json:"current_password,optional"` NewPassword string `json:"new_password,optional"` } diff --git a/apps/backend/generate/api/common.api b/apps/backend/generate/api/common.api index 1e6bc27..a4bb5e3 100644 --- a/apps/backend/generate/api/common.api +++ b/apps/backend/generate/api/common.api @@ -50,6 +50,9 @@ type MemberPublic { PostCode string `json:"post_code,optional"` PreferredLanguage string `json:"preferred_language,optional"` Currency string `json:"currency,optional"` + // pending | skipped | completed;空=尚未寫入(視同 pending,舊帳可後端補 completed) + OnboardingStatus string `json:"onboarding_status,optional"` + OnboardingDoneAt int64 `json:"onboarding_done_at,optional"` Identities []IdentityPublic `json:"identities,optional"` JoinedAt int64 `json:"joined_at,optional"` CreatedAt int64 `json:"created_at,optional"` diff --git a/apps/backend/generate/api/studio.api b/apps/backend/generate/api/studio.api index b51708c..abc3ea7 100644 --- a/apps/backend/generate/api/studio.api +++ b/apps/backend/generate/api/studio.api @@ -285,6 +285,8 @@ type ( ScheduleStartAt int64 `json:"schedule_start_at,optional"` // Threads 話題標籤(topic_tag,1~50 字,可不加 #) TopicTag string `json:"topic_tag,optional"` + // everyone | accounts_you_follow | mentioned_only | parent_post_author_only | followers_only + ReplyControl string `json:"reply_control,optional"` } // --- Own posts --- @@ -299,6 +301,8 @@ type ( RepliedAt int64 `json:"replied_at,optional"` ParentReplyId string `json:"parent_reply_id,optional"` IsMine bool `json:"is_mine,optional"` + // Threads hide_status:NOT_HUSHED / HIDDEN / … + HideStatus string `json:"hide_status,optional"` } OwnPostPublic { @@ -324,6 +328,8 @@ type ( FormulaDetail string `json:"formula_detail,optional"` Replies []OwnPostReplyPublic `json:"replies"` PublishedAt int64 `json:"published_at"` + // everyone | accounts_you_follow | mentioned_only | parent_post_author_only | followers_only + ReplyControl string `json:"reply_control,optional"` } OwnPostListData { @@ -369,6 +375,17 @@ type ( PostId string `json:"post_id"` } + OwnPostManageReplyReq { + PostId string `json:"post_id"` + ReplyId string `json:"reply_id"` + Hide bool `json:"hide"` + } + + OwnPostSetReplyControlReq { + PostId string `json:"post_id"` + ReplyControl string `json:"reply_control"` + } + // --- Mentions --- MentionPublic { Id string `json:"id"` @@ -548,6 +565,12 @@ service gateway { @handler OwnPostLoadReplies post /load-replies (OwnPostLoadRepliesReq) returns (OwnPostPublic) + + @handler OwnPostManageReply + post /manage-reply (OwnPostManageReplyReq) returns (OwnPostPublic) + + @handler OwnPostSetReplyControl + post /reply-control (OwnPostSetReplyControlReq) returns (OwnPostPublic) } @server ( diff --git a/apps/backend/internal/handler/ownposts/own_post_manage_reply_handler.go b/apps/backend/internal/handler/ownposts/own_post_manage_reply_handler.go new file mode 100644 index 0000000..618a1e1 --- /dev/null +++ b/apps/backend/internal/handler/ownposts/own_post_manage_reply_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package ownposts + +import ( + "net/http" + + "apps/backend/internal/logic/ownposts" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func OwnPostManageReplyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OwnPostManageReplyReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := ownposts.NewOwnPostManageReplyLogic(r.Context(), svcCtx) + data, err := l.OwnPostManageReply(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/ownposts/own_post_set_reply_control_handler.go b/apps/backend/internal/handler/ownposts/own_post_set_reply_control_handler.go new file mode 100644 index 0000000..cf94bc5 --- /dev/null +++ b/apps/backend/internal/handler/ownposts/own_post_set_reply_control_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package ownposts + +import ( + "net/http" + + "apps/backend/internal/logic/ownposts" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func OwnPostSetReplyControlHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OwnPostSetReplyControlReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := ownposts.NewOwnPostSetReplyControlLogic(r.Context(), svcCtx) + data, err := l.OwnPostSetReplyControl(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go index 1f7726c..81a335a 100644 --- a/apps/backend/internal/handler/routes.go +++ b/apps/backend/internal/handler/routes.go @@ -809,6 +809,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/load-replies", Handler: ownposts.OwnPostLoadRepliesHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/manage-reply", + Handler: ownposts.OwnPostManageReplyHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/reply-control", + Handler: ownposts.OwnPostSetReplyControlHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/send-reply", @@ -1540,6 +1550,35 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/utm"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPut, + Path: "/:id/branding", + Handler: workspaces.UpdateWorkspaceBrandingHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/members", + Handler: workspaces.ListWorkspaceMembersHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/members", + Handler: workspaces.AddWorkspaceMemberHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id/members/:uid", + Handler: workspaces.RemoveWorkspaceMemberHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/workspaces"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -1598,33 +1637,4 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { ), rest.WithPrefix("/api/v1/workspaces"), ) - - server.AddRoutes( - rest.WithMiddlewares( - []rest.Middleware{serverCtx.AuthJWT}, - []rest.Route{ - { - Method: http.MethodPut, - Path: "/:id/branding", - Handler: workspaces.UpdateWorkspaceBrandingHandler(serverCtx), - }, - { - Method: http.MethodGet, - Path: "/:id/members", - Handler: workspaces.ListWorkspaceMembersHandler(serverCtx), - }, - { - Method: http.MethodPost, - Path: "/:id/members", - Handler: workspaces.AddWorkspaceMemberHandler(serverCtx), - }, - { - Method: http.MethodDelete, - Path: "/:id/members/:uid", - Handler: workspaces.RemoveWorkspaceMemberHandler(serverCtx), - }, - }..., - ), - rest.WithPrefix("/api/v1/workspaces"), - ) } diff --git a/apps/backend/internal/logic/auth/login_logic.go b/apps/backend/internal/logic/auth/login_logic.go index 78a42c1..343d9c6 100644 --- a/apps/backend/internal/logic/auth/login_logic.go +++ b/apps/backend/internal/logic/auth/login_logic.go @@ -34,6 +34,7 @@ func (l *LoginLogic) Login(req *types.AuthLoginReq) (resp *types.AuthSessionData if err != nil { return nil, err } + m = ensureOnboarding(l.ctx, l.svcCtx, m) ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID) return &types.AuthSessionData{ Tokens: types.TokenPairFromAuth(pair.AccessToken, pair.RefreshToken, pair.TokenType, pair.ExpiresIn), diff --git a/apps/backend/internal/logic/auth/me_logic.go b/apps/backend/internal/logic/auth/me_logic.go index 85d2adc..a6a08c2 100644 --- a/apps/backend/internal/logic/auth/me_logic.go +++ b/apps/backend/internal/logic/auth/me_logic.go @@ -30,6 +30,7 @@ func (l *MeLogic) Me() (resp *types.MemberPublic, err error) { return nil, err } } + m = ensureOnboarding(l.ctx, l.svcCtx, m) ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID) return types.MemberFromModelWithIdentities(m, ids), nil } diff --git a/apps/backend/internal/logic/auth/onboarding.go b/apps/backend/internal/logic/auth/onboarding.go new file mode 100644 index 0000000..2957823 --- /dev/null +++ b/apps/backend/internal/logic/auth/onboarding.go @@ -0,0 +1,49 @@ +package auth + +import ( + "context" + + memberDomain "apps/backend/internal/module/member/domain" + radarDomain "apps/backend/internal/module/radar/domain" + "apps/backend/internal/svc" +) + +// ensureOnboarding fills completed for legacy members with an empty status who +// already have a brand, product, or demand watch. An explicit pending stays +// pending so a reset (for retest) is not immediately overwritten. +func ensureOnboarding(ctx context.Context, svcCtx *svc.ServiceContext, m *memberDomain.Member) *memberDomain.Member { + if m == nil || m.OnboardingStatus != "" { + return m + } + if !hasExistingSetup(ctx, svcCtx, m.UID) { + return m + } + status := memberDomain.OnboardingCompleted + updated, err := svcCtx.Auth.UpdateUserInfo(ctx, m.UID, &memberDomain.UpdateUserInfoPatch{ + OnboardingStatus: &status, + }) + if err != nil || updated == nil { + return m + } + return updated +} + +func hasExistingSetup(ctx context.Context, svcCtx *svc.ServiceContext, uid int64) bool { + if svcCtx == nil || uid <= 0 { + return false + } + if svcCtx.Scout != nil { + if brands, err := svcCtx.Scout.ListBrands(ctx, uid); err == nil && len(brands) > 0 { + return true + } + if products, err := svcCtx.Scout.ListAllProducts(ctx, uid); err == nil && len(products) > 0 { + return true + } + } + if svcCtx.Radar != nil { + if _, total, err := svcCtx.Radar.ListWatches(ctx, uid, radarDomain.WatchListFilter{Page: 1, PageSize: 1}); err == nil && total > 0 { + return true + } + } + return false +} diff --git a/apps/backend/internal/logic/compose/compose_publish_single_logic.go b/apps/backend/internal/logic/compose/compose_publish_single_logic.go index 4813520..ca87446 100644 --- a/apps/backend/internal/logic/compose/compose_publish_single_logic.go +++ b/apps/backend/internal/logic/compose/compose_publish_single_logic.go @@ -29,7 +29,7 @@ func (l *ComposePublishSingleLogic) ComposePublishSingle(req *types.ComposePubli if !ok { return nil, response.Biz(401, 401001, "missing authorization") } - b, err := l.svcCtx.Studio.PublishSingle(l.ctx, uid, req.AccountId, req.Text, req.Title, req.ImageUrls, req.ScheduleStartAt, req.TopicTag) + b, err := l.svcCtx.Studio.PublishSingle(l.ctx, uid, req.AccountId, req.Text, req.Title, req.ImageUrls, req.ScheduleStartAt, req.TopicTag, req.ReplyControl) if err != nil { return nil, err } diff --git a/apps/backend/internal/logic/ownposts/own_post_manage_reply_logic.go b/apps/backend/internal/logic/ownposts/own_post_manage_reply_logic.go new file mode 100644 index 0000000..cedee00 --- /dev/null +++ b/apps/backend/internal/logic/ownposts/own_post_manage_reply_logic.go @@ -0,0 +1,42 @@ +package ownposts + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type OwnPostManageReplyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewOwnPostManageReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OwnPostManageReplyLogic { + return &OwnPostManageReplyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *OwnPostManageReplyLogic) OwnPostManageReply(req *types.OwnPostManageReplyReq) (*types.OwnPostPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.ManageOwnPostReply(l.ctx, uid, req.PostId, req.ReplyId, req.Hide) + if err != nil { + return nil, err + } + out := types.OwnPostFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/logic/ownposts/own_post_set_reply_control_logic.go b/apps/backend/internal/logic/ownposts/own_post_set_reply_control_logic.go new file mode 100644 index 0000000..f274118 --- /dev/null +++ b/apps/backend/internal/logic/ownposts/own_post_set_reply_control_logic.go @@ -0,0 +1,42 @@ +package ownposts + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type OwnPostSetReplyControlLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewOwnPostSetReplyControlLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OwnPostSetReplyControlLogic { + return &OwnPostSetReplyControlLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *OwnPostSetReplyControlLogic) OwnPostSetReplyControl(req *types.OwnPostSetReplyControlReq) (*types.OwnPostPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.SetOwnPostReplyControl(l.ctx, uid, req.PostId, req.ReplyControl) + if err != nil { + return nil, err + } + out := types.OwnPostFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/module/member/domain/const.go b/apps/backend/internal/module/member/domain/const.go index c324e0b..de87da0 100644 --- a/apps/backend/internal/module/member/domain/const.go +++ b/apps/backend/internal/module/member/domain/const.go @@ -35,6 +35,17 @@ const ( RoleAdmin = "admin" ) +// First-run work trail — write-once on the member. +const ( + OnboardingPending = "pending" + OnboardingSkipped = "skipped" + OnboardingCompleted = "completed" +) + +func IsOnboardingTerminal(status string) bool { + return status == OnboardingSkipped || status == OnboardingCompleted +} + // MinMemberUID — 會員 uid 從一百萬起,固定 8 位數(1000000–99999999)。 // 顯示可用 %08d;內部 JSON 仍是 number。 const MinMemberUID int64 = 1_000_000 diff --git a/apps/backend/internal/module/member/domain/member.go b/apps/backend/internal/module/member/domain/member.go index f0cef2d..0d5d5a6 100644 --- a/apps/backend/internal/module/member/domain/member.go +++ b/apps/backend/internal/module/member/domain/member.go @@ -33,6 +33,9 @@ type Member struct { PostCode string `bson:"post_code,omitempty" json:"post_code,omitempty"` PreferredLanguage string `bson:"preferred_language,omitempty" json:"preferred_language,omitempty"` Currency string `bson:"currency,omitempty" json:"currency,omitempty"` + // OnboardingStatus: pending | skipped | completed. Empty is treated as pending. + OnboardingStatus string `bson:"onboarding_status,omitempty" json:"onboarding_status,omitempty"` + OnboardingDoneAt int64 `bson:"onboarding_done_at,omitempty" json:"onboarding_done_at,omitempty"` // Primary password for platform email login (also mirrored on Identity). // Must be JSON-tagged: monc Redis cache marshals with encoding/json; // json:"-" strips the hash and makes warm-cache login always fail. diff --git a/apps/backend/internal/module/member/domain/usecase.go b/apps/backend/internal/module/member/domain/usecase.go index add6169..991d1d9 100644 --- a/apps/backend/internal/module/member/domain/usecase.go +++ b/apps/backend/internal/module/member/domain/usecase.go @@ -85,6 +85,8 @@ type UpdateUserInfoPatch struct { PostCode *string PreferredLanguage *string Currency *string + OnboardingStatus *string + OnboardingDoneAt *int64 Email *string Phone *string CurrentPassword *string diff --git a/apps/backend/internal/module/member/usecase/account.go b/apps/backend/internal/module/member/usecase/account.go index a535571..3fd8ca0 100644 --- a/apps/backend/internal/module/member/usecase/account.go +++ b/apps/backend/internal/module/member/usecase/account.go @@ -111,6 +111,7 @@ func (s *AccountService) CreateUserAccount(ctx context.Context, req domain.Creat DisplayName: display, Roles: []string{domain.RoleMember}, Status: status, EmailVerified: false, InviteCode: NewInviteCode(), PasswordHash: passHash, Timezone: "Asia/Taipei", PreferredLanguage: "zh-TW", Currency: "TWD", + OnboardingStatus: domain.OnboardingPending, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.CreateMember(ctx, m); err != nil { @@ -359,6 +360,17 @@ func (s *AccountService) UpdateUserInfo(ctx context.Context, uid int64, patch *d if patch.Currency != nil { m.Currency = *patch.Currency } + if patch.OnboardingStatus != nil && !domain.IsOnboardingTerminal(m.OnboardingStatus) { + next := strings.TrimSpace(*patch.OnboardingStatus) + if next == domain.OnboardingSkipped || next == domain.OnboardingCompleted { + m.OnboardingStatus = next + if patch.OnboardingDoneAt != nil && *patch.OnboardingDoneAt > 0 { + m.OnboardingDoneAt = *patch.OnboardingDoneAt + } else if m.OnboardingDoneAt == 0 { + m.OnboardingDoneAt = domain.NowNano() + } + } + } if patch.Email != nil { newEmail := strings.ToLower(strings.TrimSpace(*patch.Email)) if newEmail != "" && newEmail != m.Email { diff --git a/apps/backend/internal/module/member/usecase/m1_t119_test.go b/apps/backend/internal/module/member/usecase/m1_t119_test.go index 7666868..624e338 100644 --- a/apps/backend/internal/module/member/usecase/m1_t119_test.go +++ b/apps/backend/internal/module/member/usecase/m1_t119_test.go @@ -306,6 +306,21 @@ func TestAP_07_ChangePasswordSuccess(t *testing.T) { require.NoError(t, err) } +func TestAP_08b_OnboardingWriteOnce(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ap08b@test.local") + require.Equal(t, domain.OnboardingPending, m.OnboardingStatus) + skipped := domain.OnboardingSkipped + out, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{OnboardingStatus: &skipped}) + require.NoError(t, err) + require.Equal(t, domain.OnboardingSkipped, out.OnboardingStatus) + require.Greater(t, out.OnboardingDoneAt, int64(0)) + completed := domain.OnboardingCompleted + out2, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{OnboardingStatus: &completed}) + require.NoError(t, err) + require.Equal(t, domain.OnboardingSkipped, out2.OnboardingStatus) +} + func TestAP_08_AvatarURLSetAndClear(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ap08@test.local") diff --git a/apps/backend/internal/module/studio/domain/domain.go b/apps/backend/internal/module/studio/domain/domain.go index 75fe7f2..5837fd0 100644 --- a/apps/backend/internal/module/studio/domain/domain.go +++ b/apps/backend/internal/module/studio/domain/domain.go @@ -57,22 +57,22 @@ func NowNano() int64 { return time.Now().UTC().UnixNano() } // Persona — PE domain type Persona struct { - ID string `bson:"_id" json:"id"` - OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` - Name string `bson:"name" json:"name"` - Brief string `bson:"brief" json:"brief"` - Status string `bson:"status" json:"status"` - Style PersonaStyle `bson:"style" json:"style"` - Guard PersonaGuard `bson:"guard" json:"guard"` - Voice string `bson:"voice,omitempty" json:"voice,omitempty"` - Notes string `bson:"notes,omitempty" json:"notes,omitempty"` + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + Name string `bson:"name" json:"name"` + Brief string `bson:"brief" json:"brief"` + Status string `bson:"status" json:"status"` + Style PersonaStyle `bson:"style" json:"style"` + Guard PersonaGuard `bson:"guard" json:"guard"` + Voice string `bson:"voice,omitempty" json:"voice,omitempty"` + Notes string `bson:"notes,omitempty" json:"notes,omitempty"` // Learning — 資產複利可見(growth-loop) - LearningSummary string `bson:"learning_summary,omitempty" json:"learning_summary,omitempty"` - LearningVersion int `bson:"learning_version,omitempty" json:"learning_version,omitempty"` - LearnedFromPostsCount int `bson:"learned_from_posts_count,omitempty" json:"learned_from_posts_count,omitempty"` - LastLearnedAt int64 `bson:"last_learned_at,omitempty" json:"last_learned_at,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + LearningSummary string `bson:"learning_summary,omitempty" json:"learning_summary,omitempty"` + LearningVersion int `bson:"learning_version,omitempty" json:"learning_version,omitempty"` + LearnedFromPostsCount int `bson:"learned_from_posts_count,omitempty" json:"learned_from_posts_count,omitempty"` + LastLearnedAt int64 `bson:"last_learned_at,omitempty" json:"last_learned_at,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } type PersonaStyle struct { @@ -172,6 +172,8 @@ type OutboxStep struct { Text string `bson:"text" json:"text"` // TopicTag — 主貼可用的 Threads 話題標籤 TopicTag string `bson:"topic_tag,omitempty" json:"topic_tag,omitempty"` + // ReplyControl — 主貼 who-can-reply(Threads reply_control,發文時寫入) + ReplyControl string `bson:"reply_control,omitempty" json:"reply_control,omitempty"` // ImageURLs — 公開 https 圖網址(Meta image_url 可抓;勿存 data URL) ImageURLs []string `bson:"image_urls,omitempty" json:"image_urls,omitempty"` Status string `bson:"status" json:"status"` @@ -213,6 +215,8 @@ type OwnPostReply struct { RepliedAt int64 `bson:"replied_at,omitempty" json:"replied_at,omitempty"` ParentReplyID string `bson:"parent_reply_id,omitempty" json:"parent_reply_id,omitempty"` IsMine bool `bson:"is_mine,omitempty" json:"is_mine,omitempty"` + // Threads hide_status:NOT_HUSHED / HIDDEN / BLOCKED / … + HideStatus string `bson:"hide_status,omitempty" json:"hide_status,omitempty"` } type OwnPost struct { @@ -239,6 +243,8 @@ type OwnPost struct { FormulaDetail string `bson:"formula_detail,omitempty" json:"formula_detail,omitempty"` Replies []OwnPostReply `bson:"replies" json:"replies"` PublishedAt int64 `bson:"published_at" json:"published_at"` + // everyone | accounts_you_follow | mentioned_only | parent_post_author_only | followers_only + ReplyControl string `bson:"reply_control,omitempty" json:"reply_control,omitempty"` } type Mention struct { @@ -289,6 +295,8 @@ type PublishRequest struct { ReplyTo string // empty = root post // TopicTag — Threads 話題標籤(API topic_tag;1~50 字,不可含 . &) TopicTag string + // ReplyControl — 主貼 who-can-reply(發文時帶入 container) + ReplyControl string // ImageURLs — 公開可抓的 http(s) 圖;空=純文字 TEXT ImageURLs []string } diff --git a/apps/backend/internal/module/studio/publish/meta.go b/apps/backend/internal/module/studio/publish/meta.go index 81ddc0d..dc8010b 100644 --- a/apps/backend/internal/module/studio/publish/meta.go +++ b/apps/backend/internal/module/studio/publish/meta.go @@ -83,6 +83,7 @@ func (t *MetaTransport) Publish(ctx context.Context, req domain.PublishRequest) if topicTag != "" { form.Set("topic_tag", topicTag) } + setReplyControl(form, req) containerID, err = t.createContainer(ctx, createURL, form) wait = 8 * time.Second case len(images) == 1: @@ -100,6 +101,7 @@ func (t *MetaTransport) Publish(ctx context.Context, req domain.PublishRequest) if topicTag != "" { form.Set("topic_tag", topicTag) } + setReplyControl(form, req) containerID, err = t.createContainer(ctx, createURL, form) wait = 30 * time.Second default: @@ -138,6 +140,7 @@ func (t *MetaTransport) Publish(ctx context.Context, req domain.PublishRequest) if topicTag != "" { form.Set("topic_tag", topicTag) } + setReplyControl(form, req) containerID, err = t.createContainer(ctx, createURL, form) wait = 30 * time.Second } @@ -328,6 +331,17 @@ func normalizeTopicTag(raw string) string { return s } +func setReplyControl(form url.Values, req domain.PublishRequest) { + if strings.TrimSpace(req.ReplyTo) != "" { + return + } + c := strings.ToLower(strings.TrimSpace(req.ReplyControl)) + switch c { + case "everyone", "accounts_you_follow", "mentioned_only", "parent_post_author_only", "followers_only": + form.Set("reply_control", c) + } +} + func (t *MetaTransport) postForm(ctx context.Context, fullURL string, form url.Values) ([]byte, int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode())) if err != nil { diff --git a/apps/backend/internal/module/studio/usecase/m4_test.go b/apps/backend/internal/module/studio/usecase/m4_test.go index e67c49e..1e32f46 100644 --- a/apps/backend/internal/module/studio/usecase/m4_test.go +++ b/apps/backend/internal/module/studio/usecase/m4_test.go @@ -386,7 +386,7 @@ func TestPL_06_PublishScheduled(t *testing.T) { setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") future := domain.NowNano() + int64(2*time.Hour) - b, err := svc.PublishSingle(context.Background(), uid, "acc1", "hello scheduled", "t", nil, future, "") + b, err := svc.PublishSingle(context.Background(), uid, "acc1", "hello scheduled", "t", nil, future, "", "") require.NoError(t, err) require.Len(t, b.Steps, 1) require.Equal(t, future, b.Steps[0].ScheduledAt) @@ -398,7 +398,7 @@ func TestPL_07_PublishDefaultNow(t *testing.T) { setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") before := domain.NowNano() - b, err := svc.PublishSingle(context.Background(), uid, "acc1", "now post", "", nil, 0, "") + b, err := svc.PublishSingle(context.Background(), uid, "acc1", "now post", "", nil, 0, "", "") require.NoError(t, err) require.InDelta(t, float64(before), float64(b.Steps[0].ScheduledAt), float64(time.Second*2)) } @@ -407,7 +407,7 @@ func TestPL_08_NoUsableAccount(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_001_008) setupUID(svc, uid) - _, err := svc.PublishSingle(context.Background(), uid, "missing", "x", "", nil, 0, "") + _, err := svc.PublishSingle(context.Background(), uid, "missing", "x", "", nil, 0, "", "") require.ErrorIs(t, err, domain.ErrNoAccount) } @@ -550,7 +550,7 @@ func TestOB_01_NotDue_NoTransport(t *testing.T) { setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") future := domain.NowNano() + int64(time.Hour) - b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "later", "", nil, future, "") + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "later", "", nil, future, "", "") n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano()) require.NoError(t, err) require.Equal(t, 0, n) @@ -564,11 +564,12 @@ func TestOB_02_DueStep_PublishedViaTransport(t *testing.T) { uid := int64(4_003_002) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") - b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "now", "", nil, 0, "") + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "now", "", nil, 0, "", "accounts_you_follow") n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano()) require.NoError(t, err) require.Equal(t, 1, n) require.Equal(t, 1, tp.CallCount()) // must go through transport + require.Equal(t, "accounts_you_follow", tp.Calls[0].ReplyControl) got, _ := svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.StepPublished, got.Steps[0].Status) require.Equal(t, domain.OBCompleted, got.Status) @@ -581,7 +582,7 @@ func TestOB_03_TransportFail(t *testing.T) { setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") tp.Fail = true - b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "fail me", "", nil, 0, "") + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "fail me", "", nil, 0, "", "") _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) got, _ := svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.StepFailed, got.Steps[0].Status) @@ -644,7 +645,7 @@ func TestOB_06_RetryStep(t *testing.T) { setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") tp.Fail = true - b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "retry", "", nil, 0, "") + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "retry", "", nil, 0, "", "") _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) tp.Fail = false got, err := svc.RetryStep(context.Background(), uid, b.ID, b.Steps[0].ID) @@ -661,7 +662,7 @@ func TestOB_07_RetryIllegalStatus(t *testing.T) { setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") // scheduled not failed - b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, domain.NowNano()+int64(time.Hour), "") + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, domain.NowNano()+int64(time.Hour), "", "") _, err := svc.RetryStep(context.Background(), uid, b.ID, b.Steps[0].ID) require.ErrorIs(t, err, domain.ErrIllegalStatus) } @@ -671,7 +672,7 @@ func TestOB_08_AllSuccessCompleted(t *testing.T) { uid := int64(4_003_008) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") - b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "done", "", nil, 0, "") + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "done", "", nil, 0, "", "") _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) got, _ := svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.OBCompleted, got.Status) @@ -705,7 +706,7 @@ func TestOB_10_RemoveStopsWorker(t *testing.T) { uid := int64(4_003_010) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") - b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "rm", "", nil, 0, "") + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "rm", "", nil, 0, "", "") require.NoError(t, svc.RemoveOutbox(context.Background(), uid, b.ID)) n, _ := svc.ProcessDueSteps(context.Background(), domain.NowNano()) require.Equal(t, 0, n) @@ -747,7 +748,7 @@ func TestOB_11_CrossUid(t *testing.T) { setupUID(svc, uid1) setupUID(svc, uid2) addAccount(acc, uid1, "acc1", "lead") - b, _ := svc.PublishSingle(context.Background(), uid1, "acc1", "priv", "", nil, domain.NowNano(), "") + b, _ := svc.PublishSingle(context.Background(), uid1, "acc1", "priv", "", nil, domain.NowNano(), "", "") _, err := svc.GetOutbox(context.Background(), uid2, b.ID) require.ErrorIs(t, err, domain.ErrForbidden) _, err = svc.RetryStep(context.Background(), uid2, b.ID, b.Steps[0].ID) @@ -760,7 +761,7 @@ func TestOB_12_LiveRequiresTransport(t *testing.T) { uid := int64(4_003_013) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") - _, _ = svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, 0, "") + _, _ = svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, 0, "", "") before := tp.CallCount() _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) require.Greater(t, tp.CallCount(), before) @@ -773,11 +774,11 @@ func TestPL_PublishRejectsPastSchedule(t *testing.T) { addAccount(acc, uid, "acc1", "lead") // 超過 1 分鐘才算過期 past := domain.NowNano() - int64(2*time.Minute) - _, err := svc.PublishSingle(context.Background(), uid, "acc1", "too late", "", nil, past, "") + _, err := svc.PublishSingle(context.Background(), uid, "acc1", "too late", "", nil, past, "", "") require.ErrorIs(t, err, domain.ErrValidation) // 略早於現在(grace 內)應可過,並 clamp 成現在 slight := domain.NowNano() - int64(5*time.Second) - b, err := svc.PublishSingle(context.Background(), uid, "acc1", "almost now", "", nil, slight, "") + b, err := svc.PublishSingle(context.Background(), uid, "acc1", "almost now", "", nil, slight, "", "") require.NoError(t, err) require.NotNil(t, b) } @@ -909,6 +910,44 @@ func TestOP_07_SyncOtherAccountForbidden(t *testing.T) { require.ErrorIs(t, err, domain.ErrForbidden) } +func TestOP_ManageReplyAndReplyControl(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_004_031) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "me") + list, err := svc.SyncOwnPosts(context.Background(), uid, "acc1") + require.NoError(t, err) + require.NotEmpty(t, list) + require.NotEmpty(t, list[0].Replies) + + _, err = svc.ManageOwnPostReply(context.Background(), uid, list[0].ID, list[0].MediaID, true) + require.Error(t, err) + + post, err := svc.ManageOwnPostReply(context.Background(), uid, list[0].ID, list[0].Replies[0].ID, true) + require.NoError(t, err) + require.Equal(t, "HIDDEN", post.Replies[0].HideStatus) + + post, err = svc.ManageOwnPostReply(context.Background(), uid, list[0].ID, list[0].Replies[0].ID, false) + require.NoError(t, err) + require.Equal(t, "NOT_HUSHED", post.Replies[0].HideStatus) + + post, err = svc.SetOwnPostReplyControl(context.Background(), uid, list[0].ID, "accounts_you_follow") + require.NoError(t, err) + require.Equal(t, "accounts_you_follow", post.ReplyControl) + + _, err = svc.SetOwnPostReplyControl(context.Background(), uid, list[0].ID, "not-a-value") + require.Error(t, err) + + svc.Media = &fakeInsightsMedia{} + _, err = svc.SetOwnPostReplyControl(context.Background(), uid, list[0].ID, "everyone") + require.Error(t, err) + require.Contains(t, err.Error(), "發文時") + + b, err := svc.PublishSingle(context.Background(), uid, "acc1", "who can reply", "", nil, 0, "", "accounts_you_follow") + require.NoError(t, err) + require.Equal(t, "accounts_you_follow", b.Steps[0].ReplyControl) +} + func TestOP_08_AnalyzePost(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_004_009) diff --git a/apps/backend/internal/module/studio/usecase/service.go b/apps/backend/internal/module/studio/usecase/service.go index 8c131f9..ef8aec2 100644 --- a/apps/backend/internal/module/studio/usecase/service.go +++ b/apps/backend/internal/module/studio/usecase/service.go @@ -52,6 +52,7 @@ type ThreadsMediaSource interface { type FetchedThread struct { ID, Text, MediaType, MediaURL, ThumbnailURL, Permalink, Shortcode, TopicTag, Username string PublishedAt int64 // unix ns + ReplyControl string } type FetchedInsights struct { @@ -64,6 +65,13 @@ type FetchedReply struct { PublishedAt int64 IsMine bool LikeCount int + HideStatus string +} + +// ThreadsReplyManager is optional; live Meta bridge implements hide / reply_control. +type ThreadsReplyManager interface { + ManageReply(ctx context.Context, accessToken, replyID string, hide bool) error + SetReplyControl(ctx context.Context, accessToken, mediaID, control string) error } // FetchedMention — Graph /{user-id}/mentions @@ -1303,6 +1311,12 @@ func (s *Service) processBundle(ctx context.Context, b *domain.OutboxBundle, now } return "" }(), + ReplyControl: func() string { + if result.Kind == domain.StepRoot { + return result.ReplyControl + } + return "" + }(), } res, perr := s.publishWithLease(ctx, b.ID, result.ID, claimOwner, leaseDuration, request) if perr != nil { @@ -1679,7 +1693,7 @@ func formatViralFormulaDetail(va *domain.ViralAnalysis) string { return strings.TrimSpace(b.String()) } -func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID, text, title string, imageURLs []string, scheduleStartAt int64, topicTag string) (*domain.OutboxBundle, error) { +func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID, text, title string, imageURLs []string, scheduleStartAt int64, topicTag, replyControl string) (*domain.OutboxBundle, error) { text = strings.TrimSpace(text) if text == "" { return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) @@ -1746,6 +1760,9 @@ func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID, if tag != "" && len(bundle.Steps) > 0 { bundle.Steps[0].TopicTag = tag } + if ctrl := threadsProvNormalizeReplyControl(replyControl); ctrl != "" && len(bundle.Steps) > 0 { + bundle.Steps[0].ReplyControl = ctrl + } if err := s.Repo.SaveOutbox(ctx, bundle); err != nil { return nil, err } @@ -1959,6 +1976,10 @@ func (s *Service) syncOwnPostsFromThreads(ctx context.Context, ownerUID int64, a if prev != nil && len(prev.Replies) > 0 { replies = append([]domain.OwnPostReply(nil), prev.Replies...) } + replyControl := strings.TrimSpace(th.ReplyControl) + if replyControl == "" && prev != nil { + replyControl = prev.ReplyControl + } p := &domain.OwnPost{ ID: id, OwnerUID: ownerUID, AccountID: accountID, MediaID: th.ID, Text: th.Text, MediaType: th.MediaType, @@ -1969,6 +1990,7 @@ func (s *Service) syncOwnPostsFromThreads(ctx context.Context, ownerUID int64, a InsightsStatus: ins.Status, Replies: replies, PublishedAt: pubAt, + ReplyControl: replyControl, } if p.MediaType == "" { p.MediaType = "TEXT_POST" @@ -2025,6 +2047,134 @@ func (s *Service) LoadOwnPostReplies(ctx context.Context, ownerUID int64, postID return post, nil } +func ownPostToken(ctx context.Context, s *Service, ownerUID int64, accountID string) (string, error) { + if s.Accounts == nil { + return "", nil + } + acc, err := s.Accounts.Get(ctx, accountID) + if err != nil || acc == nil || acc.OwnerUID != ownerUID { + return "", domain.ErrNoAccount + } + token, terr := s.Accounts.AccessToken(ctx, acc) + if terr != nil { + return "", terr + } + return strings.TrimSpace(token), nil +} + +func isRealThreadsToken(token string) bool { + return token != "" && !strings.HasPrefix(token, "fake-") +} + +// ManageOwnPostReply hides or unhides a top-level reply via Threads /manage_reply. +func (s *Service) ManageOwnPostReply(ctx context.Context, ownerUID int64, postID, replyID string, hide bool) (*domain.OwnPost, error) { + post, err := s.getOwnPostOwned(ctx, ownerUID, postID) + if err != nil { + return nil, err + } + replyID = strings.TrimSpace(replyID) + if replyID == "" { + return nil, fmt.Errorf("%w: reply_id is required", domain.ErrValidation) + } + found := false + for _, r := range post.Replies { + if r.ID == replyID { + found = true + if r.IsMine { + return nil, fmt.Errorf("%w: cannot hide your own reply", domain.ErrValidation) + } + if strings.TrimSpace(r.ParentReplyID) != "" { + return nil, fmt.Errorf("%w: only top-level replies can be hidden", domain.ErrValidation) + } + break + } + } + if !found && len(post.Replies) > 0 { + return nil, fmt.Errorf("%w: reply not found on this post", domain.ErrValidation) + } + if post.MediaID != "" && replyID == post.MediaID { + return nil, fmt.Errorf("%w: 只能隱藏別人留在這則貼文下的回覆,不能隱藏貼文本體", domain.ErrValidation) + } + token, terr := ownPostToken(ctx, s, ownerUID, post.AccountID) + if terr != nil { + return nil, terr + } + if mgr, ok := any(s.Media).(ThreadsReplyManager); ok && isRealThreadsToken(token) { + if err := mgr.ManageReply(ctx, token, replyID, hide); err != nil { + return nil, fmt.Errorf("%w: %s", domain.ErrValidation, humanizeManageReplyError(err)) + } + } else if isRealThreadsToken(token) && s.Media != nil { + return nil, fmt.Errorf("%w: reply management is not configured", domain.ErrValidation) + } + status := "NOT_HUSHED" + if hide { + status = "HIDDEN" + } + updated := false + for i := range post.Replies { + if post.Replies[i].ID == replyID || (hide && post.Replies[i].ParentReplyID == replyID) { + post.Replies[i].HideStatus = status + updated = true + } + } + if !updated { + post.Replies = append(post.Replies, domain.OwnPostReply{ID: replyID, HideStatus: status}) + } + if err := s.Repo.SaveOwnPost(ctx, post); err != nil { + return nil, err + } + return post, nil +} + +// SetOwnPostReplyControl updates who can reply (Threads reply_control). +func (s *Service) SetOwnPostReplyControl(ctx context.Context, ownerUID int64, postID, control string) (*domain.OwnPost, error) { + post, err := s.getOwnPostOwned(ctx, ownerUID, postID) + if err != nil { + return nil, err + } + control = threadsProvNormalizeReplyControl(control) + if control == "" { + return nil, fmt.Errorf("%w: reply_control must be everyone, accounts_you_follow, mentioned_only, parent_post_author_only, or followers_only", domain.ErrValidation) + } + token, terr := ownPostToken(ctx, s, ownerUID, post.AccountID) + if terr != nil { + return nil, terr + } + // Threads 官方只允許發文時帶 reply_control;對已發布 media POST 會回 code 100。 + if s.Media != nil && isRealThreadsToken(token) && strings.TrimSpace(post.MediaID) != "" { + return nil, fmt.Errorf("%w: Threads 只能在發文時設定誰可以回覆,已發布貼文無法用 API 修改。請到創作頁發一則新貼文", domain.ErrValidation) + } + post.ReplyControl = control + if err := s.Repo.SaveOwnPost(ctx, post); err != nil { + return nil, err + } + return post, nil +} + +func threadsProvNormalizeReplyControl(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + switch s { + case "everyone", "accounts_you_follow", "mentioned_only", "parent_post_author_only", "followers_only": + return s + default: + return "" + } +} + +func humanizeManageReplyError(err error) string { + if err == nil { + return "" + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "unsupported post request") || + strings.Contains(msg, "does not support this operation") || + strings.Contains(msg, "code 100") || + strings.Contains(msg, "permission") { + return "無法隱藏這則回覆。請用別人留在你貼文下的第一層回覆,並到帳號頁重新連 Threads(需授權 threads_manage_replies)" + } + return err.Error() +} + func mapFetchedReplies(convo []FetchedReply, prev *domain.OwnPost) []domain.OwnPostReply { root := "" if prev != nil { @@ -2043,7 +2193,7 @@ func mapFetchedRepliesWithRoot(convo []FetchedReply, prev *domain.OwnPost, rootM } for _, c := range convo { id := c.ID - if id == "" { + if id == "" || (rootMediaID != "" && id == rootMediaID) { continue } created := c.PublishedAt @@ -2065,10 +2215,14 @@ func mapFetchedRepliesWithRoot(convo []FetchedReply, prev *domain.OwnPost, rootM repliedBy = old.RepliedBy repliedAt = old.RepliedAt } + hideStatus := strings.TrimSpace(c.HideStatus) + if old, ok := localStatus[id]; ok && hideStatus == "" { + hideStatus = old.HideStatus + } out = append(out, domain.OwnPostReply{ ID: id, Username: c.Username, Text: c.Text, CreatedAt: created, LikeCount: c.LikeCount, ReplyStatus: status, RepliedBy: repliedBy, RepliedAt: repliedAt, - ParentReplyID: parent, IsMine: c.IsMine, + ParentReplyID: parent, IsMine: c.IsMine, HideStatus: hideStatus, }) } // 純資料:若某則留言底下有「我的」子回覆 → 標已回覆 diff --git a/apps/backend/internal/module/threads/provider/media.go b/apps/backend/internal/module/threads/provider/media.go index b421878..4ef0cb0 100644 --- a/apps/backend/internal/module/threads/provider/media.go +++ b/apps/backend/internal/module/threads/provider/media.go @@ -24,6 +24,7 @@ type RemoteThread struct { TopicTag string Username string Timestamp time.Time + ReplyControl string } // RemoteInsights — media insights @@ -47,6 +48,7 @@ type RemoteReply struct { IsMine bool ParentMediaID string LikeCount int + HideStatus string } // RemoteMention — GET /{user-id}/mentions(threads_manage_mentions) @@ -74,15 +76,25 @@ type MediaClient interface { ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]RemoteThread, error) } +const threadsListFields = "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post,reply_control" +const threadsListFieldsLegacy = "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post" + // ListThreads GET /v1.0/me/threads func (m *MetaProvider) ListThreads(ctx context.Context, accessToken string, limit int) ([]RemoteThread, error) { + list, err := m.listThreads(ctx, accessToken, limit, threadsListFields) + if err != nil && strings.Contains(strings.ToLower(err.Error()), "reply_control") { + return m.listThreads(ctx, accessToken, limit, threadsListFieldsLegacy) + } + return list, err +} + +func (m *MetaProvider) listThreads(ctx context.Context, accessToken string, limit int, fields string) ([]RemoteThread, error) { if limit <= 0 { limit = 25 } if limit > 50 { limit = 50 } - fields := "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post" q := url.Values{} q.Set("fields", fields) q.Set("limit", strconv.Itoa(limit)) @@ -104,6 +116,7 @@ func (m *MetaProvider) ListThreads(ctx context.Context, accessToken string, limi Timestamp string `json:"timestamp"` Shortcode string `json:"shortcode"` ThumbnailURL string `json:"thumbnail_url"` + ReplyControl string `json:"reply_control"` } `json:"data"` Error *graphErr `json:"error"` } @@ -119,6 +132,7 @@ func (m *MetaProvider) ListThreads(ctx context.Context, accessToken string, limi ID: d.ID, Text: d.Text, MediaType: d.MediaType, MediaURL: d.MediaURL, ThumbnailURL: d.ThumbnailURL, Permalink: d.Permalink, Shortcode: d.Shortcode, TopicTag: d.TopicTag, Username: d.Username, Timestamp: parseThreadsTime(d.Timestamp), + ReplyControl: NormalizeReplyControl(d.ReplyControl), }) } return out, nil @@ -263,9 +277,7 @@ func (m *MetaProvider) fetchReplyEdge(ctx context.Context, accessToken, mediaID, } out := make([]RemoteReply, 0, len(resp.Data)) for _, d := range resp.Data { - // 略過隱藏/封鎖 - hs := strings.ToUpper(d.HideStatus) - if hs == "HIDDEN" || hs == "BLOCKED" || hs == "RESTRICTED" || hs == "COVERED" { + if strings.TrimSpace(d.ID) == "" || d.ID == mediaID { continue } parent := "" @@ -275,20 +287,26 @@ func (m *MetaProvider) fetchReplyEdge(ctx context.Context, accessToken, mediaID, out = append(out, RemoteReply{ ID: d.ID, Text: d.Text, Username: d.Username, Timestamp: parseThreadsTime(d.Timestamp), IsMine: d.IsReplyOwnedByMe, - ParentMediaID: parent, + ParentMediaID: parent, HideStatus: strings.TrimSpace(d.HideStatus), }) } return out, nil } -// normalizeParents — 回覆根貼的 replied_to = root media id,對 UI 應視為第一層(parent 空) +// normalizeParents — 回覆根貼的 replied_to = root media id,對 UI 應視為第一層(parent 空)。 +// conversation 有時會把主貼自己也列進來,隱藏時不能拿主貼 id 打 /manage_reply。 func normalizeParents(list []RemoteReply, rootMediaID string) []RemoteReply { - for i := range list { - if list[i].ParentMediaID == rootMediaID || list[i].ParentMediaID == "" { - list[i].ParentMediaID = "" + out := make([]RemoteReply, 0, len(list)) + for _, r := range list { + if r.ID == "" || r.ID == rootMediaID { + continue } + if r.ParentMediaID == rootMediaID { + r.ParentMediaID = "" + } + out = append(out, r) } - return list + return out } type graphErr struct { @@ -335,6 +353,128 @@ func (m *MetaProvider) getJSON(ctx context.Context, fullURL string) ([]byte, err return body, nil } +// ReplyControlEveryone is Threads' default audience. +const ReplyControlEveryone = "everyone" + +// ValidReplyControls are the official Threads publishing reply_control values. +var ValidReplyControls = map[string]struct{}{ + "everyone": {}, + "accounts_you_follow": {}, + "mentioned_only": {}, + "parent_post_author_only": {}, + "followers_only": {}, +} + +func NormalizeReplyControl(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + if s == "" { + return "" + } + if _, ok := ValidReplyControls[s]; ok { + return s + } + return "" +} + +// ManageReply POST /{reply-id}/manage_reply hide=true|false +func (m *MetaProvider) ManageReply(ctx context.Context, accessToken, replyID string, hide bool) error { + replyID = strings.TrimSpace(replyID) + if replyID == "" { + return fmt.Errorf("reply id is required") + } + form := url.Values{} + if hide { + form.Set("hide", "true") + } else { + form.Set("hide", "false") + } + form.Set("access_token", accessToken) + u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(replyID) + "/manage_reply" + body, err := m.postForm(ctx, u, form) + if err != nil { + return err + } + var resp struct { + Success bool `json:"success"` + Error *graphErr `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("threads manage_reply parse: %w", err) + } + if resp.Error != nil { + return resp.Error + } + if !resp.Success { + return fmt.Errorf("threads manage_reply did not succeed") + } + return nil +} + +// SetReplyControl POST /{media-id} reply_control=… +// Official docs set this at publish time; Graph also accepts it on the published media id. +func (m *MetaProvider) SetReplyControl(ctx context.Context, accessToken, mediaID, control string) error { + mediaID = strings.TrimSpace(mediaID) + control = NormalizeReplyControl(control) + if mediaID == "" { + return fmt.Errorf("media id is required") + } + if control == "" { + return fmt.Errorf("invalid reply_control") + } + form := url.Values{} + form.Set("reply_control", control) + form.Set("access_token", accessToken) + u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(mediaID) + body, err := m.postForm(ctx, u, form) + if err != nil { + return err + } + var resp struct { + Success bool `json:"success"` + ID string `json:"id"` + Error *graphErr `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("threads reply_control parse: %w", err) + } + if resp.Error != nil { + return resp.Error + } + return nil +} + +func (m *MetaProvider) postForm(ctx context.Context, fullURL string, form url.Values) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + cli := m.HTTPClient + if cli == nil { + cli = &http.Client{Timeout: 25 * time.Second} + } + resp, err := cli.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + var ge struct { + Error *graphErr `json:"error"` + } + _ = json.Unmarshal(body, &ge) + if ge.Error != nil { + return nil, ge.Error + } + return nil, fmt.Errorf("threads graph HTTP %d: %s", resp.StatusCode, truncateBody(string(body), 200)) + } + return body, nil +} + func parseThreadsTime(s string) time.Time { s = strings.TrimSpace(s) if s == "" { diff --git a/apps/backend/internal/module/threads/provider/meta.go b/apps/backend/internal/module/threads/provider/meta.go index 89a67cd..d545290 100644 --- a/apps/backend/internal/module/threads/provider/meta.go +++ b/apps/backend/internal/module/threads/provider/meta.go @@ -15,11 +15,11 @@ import ( // MetaProvider — Meta Threads OAuth (when AppId/Secret configured). type MetaProvider struct { - AppID string - AppSecret string - HTTPClient *http.Client - AuthBase string // default https://threads.net - GraphBase string // default https://graph.threads.net + AppID string + AppSecret string + HTTPClient *http.Client + AuthBase string // default https://threads.net + GraphBase string // default https://graph.threads.net } func NewMeta(appID, appSecret string) *MetaProvider { @@ -38,7 +38,7 @@ func (m *MetaProvider) AuthorizeURL(state, redirectURI string) string { q.Set("client_id", m.AppID) q.Set("redirect_uri", redirectURI) // basic + 發文 + 回覆/對話 + insights + 提及 + 公開人設探索(對標帳號 profile_posts) - q.Set("scope", "threads_basic,threads_content_publish,threads_manage_replies,threads_manage_insights,threads_manage_mentions,threads_profile_discovery") + q.Set("scope", "threads_basic,threads_content_publish,threads_read_replies,threads_manage_replies,threads_manage_insights,threads_manage_mentions,threads_profile_discovery") q.Set("response_type", "code") q.Set("state", state) return strings.TrimRight(m.AuthBase, "/") + "/oauth/authorize?" + q.Encode() diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go index 5fc4293..d6e4502 100644 --- a/apps/backend/internal/svc/service_context.go +++ b/apps/backend/internal/svc/service_context.go @@ -471,6 +471,7 @@ func (b *metaMediaBridge) ListThreads(ctx context.Context, accessToken string, l ID: t.ID, Text: t.Text, MediaType: t.MediaType, MediaURL: t.MediaURL, ThumbnailURL: t.ThumbnailURL, Permalink: t.Permalink, Shortcode: t.Shortcode, TopicTag: t.TopicTag, Username: t.Username, PublishedAt: pub, + ReplyControl: t.ReplyControl, }) } return out, nil @@ -500,12 +501,20 @@ func (b *metaMediaBridge) ListConversation(ctx context.Context, accessToken, med } out = append(out, studioUC.FetchedReply{ ID: r.ID, Text: r.Text, Username: r.Username, ParentMediaID: r.ParentMediaID, - PublishedAt: pub, IsMine: r.IsMine, LikeCount: r.LikeCount, + PublishedAt: pub, IsMine: r.IsMine, LikeCount: r.LikeCount, HideStatus: r.HideStatus, }) } return out, nil } +func (b *metaMediaBridge) ManageReply(ctx context.Context, accessToken, replyID string, hide bool) error { + return b.Meta.ManageReply(ctx, accessToken, replyID, hide) +} + +func (b *metaMediaBridge) SetReplyControl(ctx context.Context, accessToken, mediaID, control string) error { + return b.Meta.SetReplyControl(ctx, accessToken, mediaID, control) +} + func (b *metaMediaBridge) ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]studioUC.FetchedMention, error) { list, err := b.Meta.ListMentions(ctx, accessToken, threadsUserID, limit) if err != nil { @@ -541,6 +550,7 @@ func (b *metaMediaBridge) ListProfilePosts(ctx context.Context, accessToken, use ID: t.ID, Text: t.Text, MediaType: t.MediaType, MediaURL: t.MediaURL, ThumbnailURL: t.ThumbnailURL, Permalink: t.Permalink, Shortcode: t.Shortcode, TopicTag: t.TopicTag, Username: t.Username, PublishedAt: pub, + ReplyControl: t.ReplyControl, }) } return out, nil diff --git a/apps/backend/internal/types/convert.go b/apps/backend/internal/types/convert.go index fc969f0..35f9fbd 100644 --- a/apps/backend/internal/types/convert.go +++ b/apps/backend/internal/types/convert.go @@ -25,6 +25,7 @@ func MemberFromModelWithIdentities(m *memberDomain.Member, ids []*memberDomain.I NotifyEmail: m.NotifyEmail, GenderCode: int(m.GenderCode), Birthdate: m.Birthdate, National: m.National, Address: m.Address, PostCode: m.PostCode, PreferredLanguage: m.PreferredLanguage, Currency: m.Currency, + OnboardingStatus: m.OnboardingStatus, OnboardingDoneAt: m.OnboardingDoneAt, JoinedAt: m.CreatedAt, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, } if m.EmailVerifiedAt != nil { @@ -116,6 +117,9 @@ func PatchFromAuthProfile(req *AuthProfilePatchReq) *memberDomain.UpdateUserInfo if req.Currency != "" { p.Currency = &req.Currency } + if req.OnboardingStatus != "" { + p.OnboardingStatus = &req.OnboardingStatus + } if req.CurrentPassword != "" { p.CurrentPassword = &req.CurrentPassword } diff --git a/apps/backend/internal/types/m4_convert.go b/apps/backend/internal/types/m4_convert.go index 9f44dfe..88ffa56 100644 --- a/apps/backend/internal/types/m4_convert.go +++ b/apps/backend/internal/types/m4_convert.go @@ -13,10 +13,10 @@ func PersonaFromDomain(p *domain.Persona) PersonaPublic { Identity: p.Style.Draft.Identity, Tone: p.Style.Draft.Tone, Audience: p.Style.Draft.Audience, Hooks: p.Style.Draft.Hooks, LanguageFingerprint: p.Style.Draft.LanguageFingerprint, - Rhythm: p.Style.Draft.Rhythm, Punctuation: p.Style.Draft.Punctuation, - ContentPatterns: p.Style.Draft.ContentPatterns, + Rhythm: p.Style.Draft.Rhythm, Punctuation: p.Style.Draft.Punctuation, + ContentPatterns: p.Style.Draft.ContentPatterns, KnowledgeTranslation: p.Style.Draft.KnowledgeTranslation, - CtaStyle: p.Style.Draft.CtaStyle, Examples: p.Style.Draft.Examples, + CtaStyle: p.Style.Draft.CtaStyle, Examples: p.Style.Draft.Examples, Avoid: p.Style.Draft.Avoid, }, DraftText: p.Style.DraftText, Source: p.Style.Source, @@ -66,10 +66,10 @@ func PersonaToDomain(req *PersonaSaveReq, ownerUID int64) *domain.Persona { Identity: req.Style.Draft.Identity, Tone: req.Style.Draft.Tone, Audience: req.Style.Draft.Audience, Hooks: req.Style.Draft.Hooks, LanguageFingerprint: req.Style.Draft.LanguageFingerprint, - Rhythm: req.Style.Draft.Rhythm, Punctuation: req.Style.Draft.Punctuation, - ContentPatterns: req.Style.Draft.ContentPatterns, + Rhythm: req.Style.Draft.Rhythm, Punctuation: req.Style.Draft.Punctuation, + ContentPatterns: req.Style.Draft.ContentPatterns, KnowledgeTranslation: req.Style.Draft.KnowledgeTranslation, - CtaStyle: req.Style.Draft.CtaStyle, Examples: req.Style.Draft.Examples, + CtaStyle: req.Style.Draft.CtaStyle, Examples: req.Style.Draft.Examples, Avoid: req.Style.Draft.Avoid, }, DraftText: req.Style.DraftText, Source: req.Style.Source, @@ -179,6 +179,7 @@ func OwnPostFromDomain(p *domain.OwnPost) OwnPostPublic { Id: r.ID, Username: r.Username, Text: r.Text, CreatedAt: r.CreatedAt, LikeCount: r.LikeCount, ReplyStatus: r.ReplyStatus, RepliedBy: r.RepliedBy, RepliedAt: r.RepliedAt, ParentReplyId: r.ParentReplyID, IsMine: r.IsMine, + HideStatus: r.HideStatus, }) } return OwnPostPublic{ @@ -189,7 +190,7 @@ func OwnPostFromDomain(p *domain.OwnPost) OwnPostPublic { QuoteCount: p.QuoteCount, ViewCount: p.ViewCount, ShareCount: p.ShareCount, InsightsStatus: p.InsightsStatus, FormulaSummary: p.FormulaSummary, Insight: p.Insight, FormulaDetail: p.FormulaDetail, Replies: replies, - PublishedAt: p.PublishedAt, + PublishedAt: p.PublishedAt, ReplyControl: p.ReplyControl, } } diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go index 4fef013..a140da6 100644 --- a/apps/backend/internal/types/types.go +++ b/apps/backend/internal/types/types.go @@ -194,6 +194,7 @@ type AuthProfilePatchReq struct { PostCode string `json:"post_code,optional"` PreferredLanguage string `json:"preferred_language,optional"` Currency string `json:"currency,optional"` + OnboardingStatus string `json:"onboarding_status,optional"` CurrentPassword string `json:"current_password,optional"` NewPassword string `json:"new_password,optional"` } @@ -391,6 +392,7 @@ type ComposePublishReq struct { ImageUrls []string `json:"image_urls,optional"` ScheduleStartAt int64 `json:"schedule_start_at,optional"` TopicTag string `json:"topic_tag,optional"` + ReplyControl string `json:"reply_control,optional"` } type ComposeViralReq struct { @@ -1190,6 +1192,8 @@ type MemberPublic struct { PostCode string `json:"post_code,optional"` PreferredLanguage string `json:"preferred_language,optional"` Currency string `json:"currency,optional"` + OnboardingStatus string `json:"onboarding_status,optional"` + OnboardingDoneAt int64 `json:"onboarding_done_at,optional"` Identities []IdentityPublic `json:"identities,optional"` JoinedAt int64 `json:"joined_at,optional"` CreatedAt int64 `json:"created_at,optional"` @@ -1467,6 +1471,12 @@ type OwnPostLoadRepliesReq struct { PostId string `json:"post_id"` } +type OwnPostManageReplyReq struct { + PostId string `json:"post_id"` + ReplyId string `json:"reply_id"` + Hide bool `json:"hide"` +} + type OwnPostPublic struct { Id string `json:"id"` AccountId string `json:"account_id"` @@ -1490,6 +1500,7 @@ type OwnPostPublic struct { FormulaDetail string `json:"formula_detail,optional"` Replies []OwnPostReplyPublic `json:"replies"` PublishedAt int64 `json:"published_at"` + ReplyControl string `json:"reply_control,optional"` } type OwnPostReplyPublic struct { @@ -1503,6 +1514,7 @@ type OwnPostReplyPublic struct { RepliedAt int64 `json:"replied_at,optional"` ParentReplyId string `json:"parent_reply_id,optional"` IsMine bool `json:"is_mine,optional"` + HideStatus string `json:"hide_status,optional"` } type OwnPostSendReplyReq struct { @@ -1513,6 +1525,11 @@ type OwnPostSendReplyReq struct { ImageUrls []string `json:"image_urls,optional"` } +type OwnPostSetReplyControlReq struct { + PostId string `json:"post_id"` + ReplyControl string `json:"reply_control"` +} + type OwnPostSyncReq struct { AccountId string `json:"account_id"` } diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index e2eb381..220796e 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -1,7 +1,9 @@ import { Suspense } from "react"; import { Outlet } from "react-router-dom"; import { JobLiveProvider } from "../../data/JobLiveContext"; +import { FirstRunProvider } from "../../firstRun/FirstRunContext"; import { ActiveJobsStrip } from "./ActiveJobsStrip"; +import { FirstRunBar } from "./FirstRunBar"; import { MobileDock } from "./MobileDock"; import { PageHelpProvider } from "./PageHelp"; import { SidebarNav } from "./SidebarNav"; @@ -11,10 +13,12 @@ export function AppShell() { return ( +
+
@@ -36,6 +40,7 @@ export function AppShell() {
+
); diff --git a/apps/web/src/components/layout/FirstRunBar.tsx b/apps/web/src/components/layout/FirstRunBar.tsx new file mode 100644 index 0000000..61695a3 --- /dev/null +++ b/apps/web/src/components/layout/FirstRunBar.tsx @@ -0,0 +1,23 @@ +import { Link } from "react-router-dom"; +import { useI18n } from "../../i18n/I18nContext"; +import { useFirstRun } from "../../firstRun/FirstRunContext"; + +export function FirstRunBar() { + const { t } = useI18n(); + const { active, current } = useFirstRun(); + if (!active || !current) return null; + + return ( +
+
+ {t("firstRun.title")} +

{t("firstRun.subtitle")}

+
+
+ + {t("firstRun.step.crew")} + +
+
+ ); +} diff --git a/apps/web/src/components/layout/MobileDock.tsx b/apps/web/src/components/layout/MobileDock.tsx index 86a8e7c..05c3d6b 100644 --- a/apps/web/src/components/layout/MobileDock.tsx +++ b/apps/web/src/components/layout/MobileDock.tsx @@ -1,7 +1,9 @@ import { useEffect, useId, useRef, useState } from "react"; import { NavLink, useLocation, useNavigate } from "react-router-dom"; import { useI18n } from "../../i18n/I18nContext"; +import { useFirstRun } from "../../firstRun/FirstRunContext"; import { + firstRunNavKeys, isMoreNavActive, isNavActive, mobileDockMoreKeys, @@ -15,14 +17,15 @@ export function MobileDock() { const { pathname } = useLocation(); const { t } = useI18n(); const navigate = useNavigate(); + const { active: firstRun } = useFirstRun(); const [moreOpen, setMoreOpen] = useState(false); const sheetRef = useRef(null); const moreBtnRef = useRef(null); const titleId = useId(); - const dockItems = navItemsByKeys(mobileDockPrimaryKeys); - const moreGroups = navGroupedItemsByKeys(mobileDockMoreKeys); - const moreActive = isMoreNavActive(pathname); + const dockItems = navItemsByKeys(firstRun ? firstRunNavKeys : mobileDockPrimaryKeys); + const moreGroups = firstRun ? [] : navGroupedItemsByKeys(mobileDockMoreKeys); + const moreActive = !firstRun && isMoreNavActive(pathname); useEffect(() => { setMoreOpen(false); @@ -128,6 +131,7 @@ export function MobileDock() { ); })} + {firstRun ? null : ( + )} ); diff --git a/apps/web/src/components/layout/SidebarNav.tsx b/apps/web/src/components/layout/SidebarNav.tsx index fbd159c..fd59263 100644 --- a/apps/web/src/components/layout/SidebarNav.tsx +++ b/apps/web/src/components/layout/SidebarNav.tsx @@ -1,20 +1,25 @@ import { NavLink, useLocation } from "react-router-dom"; +import { useFirstRun } from "../../firstRun/FirstRunContext"; import { useI18n } from "../../i18n/I18nContext"; -import { isNavActive, navGroups, navItemsByKeys } from "../../lib/nav"; +import { firstRunNavKeys, isNavActive, navGroups, navGroupedItemsByKeys, navItemsByKeys } from "../../lib/nav"; import { AppIcon } from "../ui/AppIcons"; export function SidebarNav() { const { pathname } = useLocation(); const { t } = useI18n(); + const { active } = useFirstRun(); + const groups = active + ? navGroupedItemsByKeys(firstRunNavKeys) + : navGroups.map((group) => ({ group, items: navItemsByKeys(group.keys) })); return (