Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/comment-automation/references/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
| `onlyCommentsWithImage` reply filter (`commentAutomationChannelSupportsImageFilter`) | ✅ | ❌ | ❌ | ✅ | ❌ |
| GIF hide (`commentAutomationChannelSupportsHideGif`) | ✅ | ❌ | ❌ | ✅ | ❌ |
| tag tracking / `mentions` filter | ✅ exact | ✅ text | ✅ text | ✅ text | ✅ text |
| post hashtag target (`post.type: "hashtags"`, caption read via `post-caption.ts`) | ✅ | ✅ | ✅ | ✅ | ⚠️ needs `video.list` |

Threads has no DM API at all; it hides only TOP-LEVEL replies (`manage_reply` — the worker
skips nested ones via `supportsHideForComment`) and
Expand Down
113 changes: 113 additions & 0 deletions apps/builder/__tests__/comment-automation-post-hashtags.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { act } from "react"
import { createRoot } from "react-dom/client"
import { describe, expect, test, vi } from "vitest"
import { toChannelPostTarget } from "../src/features/shared/comment-automation/lib/channel-post-target"
import { refinePostHashtags } from "../src/features/shared/comment-automation/lib/post-hashtags-validation"
import { useClearPostValueOnHashtagsSwitch } from "../src/features/shared/comment-automation/use-clear-post-value-on-hashtags-switch"
import {
createThreadsCommentRequest,
threadsCommentValidationKeys,
} from "../src/features/threads-comments/schema/action"

const threadsPayload = (post: { type: string; value: string[] }) => ({
name: "Auto reply",
post,
publicReply: { type: "none", value: null },
includeKeywords: { type: "all", value: [] },
excludeKeywords: [],
options: {
replyToNewContactsOnly: false,
replyOncePerUserPerPost: false,
replyToUsersWhoCommentedOnOtherPosts: true,
ignoreCommentReplies: true,
},
replyAfter: { type: "immediately", value: 0 },
})

describe("toChannelPostTarget", () => {
test("keeps a hashtags target and its list", () => {
expect(toChannelPostTarget({ type: "hashtags", value: ["vip"] })).toEqual({
type: "hashtags",
value: ["vip"],
})
})

test("reads a Meta-only type as all", () => {
expect(toChannelPostTarget({ type: "reels", value: [] })).toEqual({
type: "all",
value: [],
})
})
})

describe("threads hashtags target", () => {
test("accepts a hashtag list", () => {
const parsed = createThreadsCommentRequest.parse(
threadsPayload({ type: "hashtags", value: ["vip"] }),
)
expect(parsed.post).toEqual({ type: "hashtags", value: ["vip"] })
})

test("rejects an empty hashtag list", () => {
expect(() =>
createThreadsCommentRequest.parse(
threadsPayload({ type: "hashtags", value: [] }),
),
).toThrow(threadsCommentValidationKeys.hashtagsRequired)
})
})

describe("refinePostHashtags", () => {
test("flags an empty hashtags target on post.value", () => {
const addIssue = vi.fn()
refinePostHashtags("required")({ post: { type: "hashtags", value: [] } }, {
addIssue,
} as never)
expect(addIssue).toHaveBeenCalledWith(
expect.objectContaining({ path: ["post", "value"], message: "required" }),
)
})

test("ignores every other target", () => {
const addIssue = vi.fn()
refinePostHashtags("required")({ post: { type: "postIds", value: [] } }, {
addIssue,
} as never)
expect(addIssue).not.toHaveBeenCalled()
})
})

function Probe({ type, clear }: { type: string; clear: () => void }) {
useClearPostValueOnHashtagsSwitch(type, clear)
return null
}

/** Renders the hook through each post type in turn. */
function renderTypes(types: string[], clear: () => void) {
const container = document.createElement("div")
const root = createRoot(container)
for (const type of types) {
act(() => root.render(<Probe clear={clear} type={type} />))
}
act(() => root.unmount())
}

describe("useClearPostValueOnHashtagsSwitch", () => {
test("never clears on the first render, so an edit keeps its list", () => {
const clear = vi.fn()
renderTypes(["hashtags"], clear)
expect(clear).not.toHaveBeenCalled()
})

test("clears when switching between post ids and hashtags", () => {
const clear = vi.fn()
renderTypes(["postIds", "hashtags", "postIds"], clear)
expect(clear).toHaveBeenCalledTimes(2)
})

test("keeps the list on a switch that does not involve hashtags", () => {
const clear = vi.fn()
renderTypes(["postIds", "all"], clear)
expect(clear).not.toHaveBeenCalled()
})
})
25 changes: 21 additions & 4 deletions apps/builder/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,9 @@
"engagedOnOtherPost": "تفاعل مسبقًا على منشور آخر",
"mentionCountNotMatched": "عدد الإشارات غير مطابق",
"noImageAttachment": "لا توجد صورة مرفقة",
"moderated": "تم إخفاؤه أو حذفه بواسطة الإشراف"
"moderated": "تم إخفاؤه أو حذفه بواسطة الإشراف",
"postHashtagNotMatched": "المنشور لا يحتوي على وسم مطابق",
"postCaptionUnavailable": "تعذّرت قراءة نص المنشور"
},
"replyTo": {
"mentions": "التعليقات التي تحتوي على عدد كافٍ من الإشارات",
Expand Down Expand Up @@ -903,6 +905,15 @@
"previous": "السابق",
"next": "التالي",
"page": "الصفحة {page} من {pageCount}"
},
"postType": {
"hashtags": "المنشورات التي تحتوي على وسوم"
},
"postHashtags": {
"label": "الوسوم",
"description": "الرد فقط على المنشورات التي يحتوي نصها على وسم واحد على الأقل من هذه الوسوم. اكتب الوسم واضغط Enter. يتم التعرف على الوسم المضاف إلى منشور لاحقًا خلال 10 دقائق تقريبًا.",
"placeholder": "مثال: automation",
"required": "أضف وسمًا واحدًا على الأقل."
}
},
"condition": {
Expand Down Expand Up @@ -7830,7 +7841,8 @@
"delayMustBeZero": "Delay value must be zero for the selected timing option.",
"atLeastOneFieldRequired": "At least one field is required.",
"replyTextRequired": "أدخل رسالة رد واحدة على الأقل.",
"hideKeywordsRequired": "Enter at least one keyword to hide comments by keyword."
"hideKeywordsRequired": "Enter at least one keyword to hide comments by keyword.",
"hashtagsRequired": "أضف وسمًا واحدًا على الأقل."
},
"hideCommentsDescription": "يمكن إخفاء الردود المباشرة على منشوراتك فقط.",
"hideComments": {
Expand Down Expand Up @@ -7946,13 +7958,18 @@
"delayMustBeZero": "Delay value must be zero for the selected timing option.",
"hideKeywordsRequired": "Enter at least one keyword to hide comments by keyword.",
"atLeastOneFieldRequired": "At least one field is required.",
"replyTextRequired": "أدخل رسالة رد واحدة على الأقل."
"replyTextRequired": "أدخل رسالة رد واحدة على الأقل.",
"hashtagsRequired": "أضف وسمًا واحدًا على الأقل."
},
"privateReply": "Private reply (Comment-to-Message)",
"privateReplyDescription": "Send a direct message to the commenter. TikTok only allows this for comments it flags as high intent, so it will not fire for every matching comment.",
"privateReplyMessage": "Private reply message",
"privateReplyHighIntentTitle": "TikTok decides which comments can be messaged",
"privateReplyHighIntentNotice": "Comment-to-Message must be enabled on the connected TikTok account, and the account must be a Business Account registered in Vietnam, Indonesia or Thailand. TikTok sends the direct message only for first-level comments it flags as high intent, within 48 hours, once per comment, and not if the commenter already messaged you in the past 24 hours."
"privateReplyHighIntentNotice": "Comment-to-Message must be enabled on the connected TikTok account, and the account must be a Business Account registered in Vietnam, Indonesia or Thailand. TikTok sends the direct message only for first-level comments it flags as high intent, within 48 hours, once per comment, and not if the commenter already messaged you in the past 24 hours.",
"postHashtags": {
"reconnectRequired": "لا يمكن لحساب TikTok هذا قراءة أوصاف الفيديو بعد، لذلك سيتم تخطي كل التعليقات. أعد ربط الحساب من الإعدادات ← القنوات ← TikTok للسماح بذلك.",
"someAccountsReconnectRequired": "بعض حسابات TikTok لا يمكنها قراءة أوصاف الفيديو بعد، لذلك سيتم تخطي تعليقاتها. أعد ربطها من الإعدادات ← القنوات ← TikTok."
}
},
"commentAutomationMissedComments": {
"action": "معالجة التعليقات الفائتة",
Expand Down
25 changes: 21 additions & 4 deletions apps/builder/messages/az.json
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,9 @@
"engagedOnOtherPost": "Başqa paylaşımla artıq qarşılıqlı əlaqə qurulub",
"mentionCountNotMatched": "Etiketlənən şəxslərin sayı uyğun gəlmədi",
"noImageAttachment": "Şəkil əlavə edilməyib",
"moderated": "Moderasiya tərəfindən gizlədildi və ya silindi"
"moderated": "Moderasiya tərəfindən gizlədildi və ya silindi",
"postHashtagNotMatched": "Paylaşımda uyğun heşteq yoxdur",
"postCaptionUnavailable": "Paylaşımın mətni oxunmadı"
},
"replyTo": {
"mentions": "Kifayət qədər şəxsin etiketləndiyi şərhlər",
Expand Down Expand Up @@ -945,6 +947,15 @@
"previous": "Əvvəlki",
"next": "Növbəti",
"page": "Səhifə {page} / {pageCount}"
},
"postType": {
"hashtags": "Heşteqli paylaşımlar"
},
"postHashtags": {
"label": "Heşteqlər",
"description": "Yalnız mətnində bu heşteqlərdən ən azı biri olan paylaşımlara cavab verin. Heşteqi yazıb Enter düyməsini basın. Paylaşıma sonradan əlavə edilən heşteq təxminən 10 dəqiqə ərzində tanınır.",
"placeholder": "məs. automation",
"required": "Ən azı bir heşteq əlavə edin."
}
},
"condition": {
Expand Down Expand Up @@ -7534,7 +7545,8 @@
"delayMustBeZero": "Seçilmiş vaxt seçimi üçün gecikmə dəyəri sıfır olmalıdır.",
"atLeastOneFieldRequired": "Ən azı bir sahə məcburidir.",
"replyTextRequired": "Ən azı bir cavab mesajı daxil edin.",
"hideKeywordsRequired": "Şərhləri açar sözə görə gizlətmək üçün ən azı bir açar söz daxil edin."
"hideKeywordsRequired": "Şərhləri açar sözə görə gizlətmək üçün ən azı bir açar söz daxil edin.",
"hashtagsRequired": "Ən azı bir heşteq əlavə edin."
},
"hideCommentsDescription": "Yalnız paylaşımlarınıza birbaşa verilən cavablar gizlədilə bilər.",
"hideComments": {
Expand Down Expand Up @@ -7650,13 +7662,18 @@
"delayMustBeZero": "Seçilmiş vaxt seçimi üçün gecikmə dəyəri sıfır olmalıdır.",
"hideKeywordsRequired": "Şərhləri açar sözə görə gizlətmək üçün ən azı bir açar söz daxil edin.",
"atLeastOneFieldRequired": "Ən azı bir sahə doldurulmalıdır.",
"replyTextRequired": "Ən azı bir cavab mesajı daxil edin."
"replyTextRequired": "Ən azı bir cavab mesajı daxil edin.",
"hashtagsRequired": "Ən azı bir heşteq əlavə edin."
},
"privateReply": "Şəxsi cavab (şərhdən mesaja)",
"privateReplyDescription": "Şərh müəllifinə birbaşa mesaj göndərin. TikTok buna yalnız yüksək maraq ifadə etdiyini müəyyənləşdirdiyi şərhlər üçün icazə verir, buna görə hər uyğun şərh üçün mesaj göndərilməyəcək.",
"privateReplyMessage": "Şəxsi cavab mesajı",
"privateReplyHighIntentTitle": "Hansı şərhlərə mesaj göndərilə biləcəyini TikTok müəyyən edir",
"privateReplyHighIntentNotice": "Qoşulmuş TikTok hesabında şərhdən mesaja funksiyası aktiv olmalı və hesab Vyetnam, İndoneziya və ya Taylandda qeydiyyatdan keçmiş biznes hesabı olmalıdır. TikTok birbaşa mesajı yalnız yüksək maraq ifadə etdiyini müəyyənləşdirdiyi, videoya birbaşa yazılmış şərhlər üçün, 48 saat ərzində və hər şərhə bir dəfə göndərir. Şərh müəllifi son 24 saat ərzində sizə artıq mesaj göndəribsə, mesaj göndərilmir."
"privateReplyHighIntentNotice": "Qoşulmuş TikTok hesabında şərhdən mesaja funksiyası aktiv olmalı və hesab Vyetnam, İndoneziya və ya Taylandda qeydiyyatdan keçmiş biznes hesabı olmalıdır. TikTok birbaşa mesajı yalnız yüksək maraq ifadə etdiyini müəyyənləşdirdiyi, videoya birbaşa yazılmış şərhlər üçün, 48 saat ərzində və hər şərhə bir dəfə göndərir. Şərh müəllifi son 24 saat ərzində sizə artıq mesaj göndəribsə, mesaj göndərilmir.",
"postHashtags": {
"reconnectRequired": "Bu TikTok hesabı hələ video mətnlərini oxuya bilmir, buna görə bütün şərhlər ötürüləcək. İcazə vermək üçün hesabı Parametrlər → Kanallar → TikTok bölməsində yenidən qoşun.",
"someAccountsReconnectRequired": "Bəzi TikTok hesablarınız hələ video mətnlərini oxuya bilmir, buna görə onların şərhləri ötürüləcək. Onları Parametrlər → Kanallar → TikTok bölməsində yenidən qoşun."
}
},
"commentAutomationMissedComments": {
"action": "Buraxılmış şərhləri emal et",
Expand Down
25 changes: 21 additions & 4 deletions apps/builder/messages/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,9 @@
"engagedOnOtherPost": "Har allerede interageret på et andet opslag",
"mentionCountNotMatched": "Antal omtaler matchede ikke",
"noImageAttachment": "Intet billede vedhæftet",
"moderated": "Skjult eller slettet af moderering"
"moderated": "Skjult eller slettet af moderering",
"postHashtagNotMatched": "Opslaget har intet matchende hashtag",
"postCaptionUnavailable": "Opslagets tekst kunne ikke læses"
},
"replyTo": {
"mentions": "Kommentarer med nok omtaler",
Expand Down Expand Up @@ -903,6 +905,15 @@
"previous": "Forrige",
"next": "Næste",
"page": "Side {page} af {pageCount}"
},
"postType": {
"hashtags": "Opslag med hashtags"
},
"postHashtags": {
"label": "Hashtags",
"description": "Svar kun på opslag, hvis tekst indeholder mindst ét af disse hashtags. Skriv et hashtag, og tryk på Enter. Et hashtag, der tilføjes et opslag senere, registreres inden for ca. 10 minutter.",
"placeholder": "f.eks. automation",
"required": "Tilføj mindst ét hashtag."
}
},
"condition": {
Expand Down Expand Up @@ -6800,7 +6811,8 @@
"delayMustBeZero": "Delay value must be zero for the selected timing option.",
"atLeastOneFieldRequired": "At least one field is required.",
"replyTextRequired": "Angiv mindst én svarbesked.",
"hideKeywordsRequired": "Enter at least one keyword to hide comments by keyword."
"hideKeywordsRequired": "Enter at least one keyword to hide comments by keyword.",
"hashtagsRequired": "Tilføj mindst ét hashtag."
},
"hideCommentsDescription": "Kun svar på øverste niveau til dine opslag kan skjules.",
"hideComments": {
Expand Down Expand Up @@ -6916,13 +6928,18 @@
"delayMustBeZero": "Delay value must be zero for the selected timing option.",
"hideKeywordsRequired": "Enter at least one keyword to hide comments by keyword.",
"atLeastOneFieldRequired": "At least one field is required.",
"replyTextRequired": "Angiv mindst én svarbesked."
"replyTextRequired": "Angiv mindst én svarbesked.",
"hashtagsRequired": "Tilføj mindst ét hashtag."
},
"privateReply": "Private reply (Comment-to-Message)",
"privateReplyDescription": "Send a direct message to the commenter. TikTok only allows this for comments it flags as high intent, so it will not fire for every matching comment.",
"privateReplyMessage": "Private reply message",
"privateReplyHighIntentTitle": "TikTok decides which comments can be messaged",
"privateReplyHighIntentNotice": "Comment-to-Message must be enabled on the connected TikTok account, and the account must be a Business Account registered in Vietnam, Indonesia or Thailand. TikTok sends the direct message only for first-level comments it flags as high intent, within 48 hours, once per comment, and not if the commenter already messaged you in the past 24 hours."
"privateReplyHighIntentNotice": "Comment-to-Message must be enabled on the connected TikTok account, and the account must be a Business Account registered in Vietnam, Indonesia or Thailand. TikTok sends the direct message only for first-level comments it flags as high intent, within 48 hours, once per comment, and not if the commenter already messaged you in the past 24 hours.",
"postHashtags": {
"reconnectRequired": "Denne TikTok-konto kan endnu ikke læse videotekster, så alle kommentarer springes over. Forbind kontoen igen under Indstillinger → Kanaler → TikTok for at tillade det.",
"someAccountsReconnectRequired": "Nogle af dine TikTok-konti kan endnu ikke læse videotekster, så deres kommentarer springes over. Forbind dem igen under Indstillinger → Kanaler → TikTok."
}
},
"commentAutomationMissedComments": {
"action": "Behandl oversete kommentarer",
Expand Down
Loading
Loading