Browse Source

Auto Comment Concept

Benjamin Harris 1 month ago
parent
commit
2348f3e2e4

+ 4 - 1
.claude/settings.local.json

@@ -1,7 +1,10 @@
 {
   "permissions": {
     "allow": [
-      "Bash(dir \"d:\\\\GIT_REPO\\\\social-media-manager\\\\services\\\\gateway\" /s /b)"
+      "Bash(dir \"d:\\\\GIT_REPO\\\\social-media-manager\\\\services\\\\gateway\" /s /b)",
+      "Bash(sed -n '110,155p' d:/GIT_REPO/social-media-manager/services/facebook/index.js)",
+      "Bash(sed -n '85,125p' d:/GIT_REPO/social-media-manager/services/mastodon/index.js)",
+      "Bash(sed -n '90,130p' d:/GIT_REPO/social-media-manager/services/bluesky/index.js)"
     ]
   }
 }

+ 20 - 1
services/bluesky/index.js

@@ -96,7 +96,7 @@ class BlueskyService extends BasePlatformService {
     return items;
   }
 
-  async publishPost({ content, media = [] } = {}) {
+  async publishPost({ content, media = [], firstComment } = {}) {
     await this._ensureLoggedIn();
 
     const rt = new RichText({ text: content });
@@ -109,6 +109,25 @@ class BlueskyService extends BasePlatformService {
     }
 
     const result = await this.agent.post(postData);
+
+    if (firstComment?.trim()) {
+      try {
+        const commentRt = new RichText({ text: firstComment.trim() });
+        await commentRt.detectFacets(this.agent);
+        await this.agent.post({
+          text: commentRt.text,
+          facets: commentRt.facets,
+          reply: {
+            root: { uri: result.uri, cid: result.cid },
+            parent: { uri: result.uri, cid: result.cid },
+          },
+        });
+        this.app.log.info({ action: 'first_comment', platform: 'bluesky', uri: result.uri, outcome: 'success' });
+      } catch (err) {
+        this.app.log.warn({ action: 'first_comment', platform: 'bluesky', uri: result.uri, outcome: 'failure', err: err.message });
+      }
+    }
+
     return { uri: result.uri, cid: result.cid };
   }
 }

+ 16 - 2
services/facebook/index.js

@@ -112,7 +112,7 @@ class FacebookService extends BasePlatformService {
     return allItems;
   }
 
-  async publishPost({ content, link, imageUrl, accountId } = {}) {
+  async publishPost({ content, link, imageUrl, accountId, firstComment } = {}) {
     const allPages = await this._getPages();
     if (allPages.length === 0) throw new Error('No Facebook Pages connected');
     if (!content) throw new Error('content is required');
@@ -128,7 +128,21 @@ class FacebookService extends BasePlatformService {
       if (imageUrl) params.picture = imageUrl;
 
       const res = await axios.post(`${GRAPH_API}/${page.id}/feed`, null, { params });
-      results.push({ pageId: page.id, pageName: page.name, postId: res.data.id });
+      const postId = res.data.id;
+
+      if (firstComment?.trim()) {
+        try {
+          await axios.post(`${GRAPH_API}/${postId}/comments`, null, {
+            params: { message: firstComment.trim(), access_token: page.accessToken },
+            timeout: 10000,
+          });
+          this.app.log.info({ action: 'first_comment', platform: 'facebook', postId, outcome: 'success' });
+        } catch (err) {
+          this.app.log.warn({ action: 'first_comment', platform: 'facebook', postId, outcome: 'failure', err: err.response?.data?.error?.message || err.message });
+        }
+      }
+
+      results.push({ pageId: page.id, pageName: page.name, postId });
     }
 
     return results;

+ 7 - 2
services/gateway/server.js

@@ -958,7 +958,7 @@ const PLATFORM_SERVICES = {
 // Direct multi-platform post endpoint.
 // Body: { content: string, destinations: Array<{ platform, accountId?, imageUrl?, videoUrl?, link? }> }
 app.post('/post', async (request, reply) => {
-  const { content, destinations = [] } = request.body || {};
+  const { content, destinations = [], firstComment } = request.body || {};
   if (!content?.trim()) return reply.code(400).send({ error: 'content is required' });
   if (!destinations.length) return reply.code(400).send({ error: 'destinations must not be empty' });
 
@@ -966,7 +966,11 @@ app.post('/post', async (request, reply) => {
     destinations.map(async ({ platform, accountId, imageUrl, videoUrl, link }) => {
       const serviceUrl = PLATFORM_SERVICES[platform];
       if (!serviceUrl) throw new Error(`Unknown platform: ${platform}`);
-      const res = await axios.post(`${serviceUrl}/post`, { content, accountId, imageUrl, videoUrl, link }, { timeout: 30000 });
+      const res = await axios.post(
+        `${serviceUrl}/post`,
+        { content, accountId, imageUrl, videoUrl, link, firstComment: firstComment?.trim() || undefined },
+        { timeout: 30000 }
+      );
       return { platform, accountId, ...res.data };
     })
   );
@@ -988,6 +992,7 @@ app.post('/post', async (request, reply) => {
       _id: crypto.randomUUID(),
       type: 'immediate',
       content,
+      ...(firstComment?.trim() && { firstComment: firstComment.trim() }),
       destinations,
       platformResults: Object.fromEntries(
         output.map((r) => [

+ 16 - 2
services/instagram/index.js

@@ -119,7 +119,7 @@ class InstagramService extends BasePlatformService {
   }
 
   // Instagram requires media (image_url or video_url) — text-only posts are not supported.
-  async publishPost({ content, imageUrl, videoUrl, accountId } = {}) {
+  async publishPost({ content, imageUrl, videoUrl, accountId, firstComment } = {}) {
     const allAccounts = await this._getAccounts();
     if (allAccounts.length === 0) throw new Error('No Instagram accounts connected');
 
@@ -154,7 +154,21 @@ class InstagramService extends BasePlatformService {
         null,
         { params: { creation_id: containerRes.data.id, access_token: account.accessToken } }
       );
-      results.push({ accountId: account.id, username: account.username, postId: publishRes.data.id });
+      const postId = publishRes.data.id;
+
+      if (firstComment?.trim()) {
+        try {
+          await axios.post(`${GRAPH_API}/${postId}/comments`, null, {
+            params: { message: firstComment.trim(), access_token: account.accessToken },
+            timeout: 10000,
+          });
+          this.app.log.info({ action: 'first_comment', platform: 'instagram', postId, outcome: 'success' });
+        } catch (err) {
+          this.app.log.warn({ action: 'first_comment', platform: 'instagram', postId, outcome: 'failure', err: err.response?.data?.error?.message || err.message });
+        }
+      }
+
+      results.push({ accountId: account.id, username: account.username, postId });
     }
 
     return results;

+ 13 - 1
services/mastodon/index.js

@@ -92,7 +92,7 @@ class MastodonService extends BasePlatformService {
     return items;
   }
 
-  async publishPost({ content, media = [], sensitive = false, spoilerText = '' } = {}) {
+  async publishPost({ content, media = [], sensitive = false, spoilerText = '', firstComment } = {}) {
     const client = this._initClient();
     if (!client) throw new Error('Mastodon access token not configured');
 
@@ -103,6 +103,18 @@ class MastodonService extends BasePlatformService {
       mediaIds: media,
     });
 
+    if (firstComment?.trim()) {
+      try {
+        await client.v1.statuses.create({
+          status: firstComment.trim(),
+          inReplyToId: status.id,
+        });
+        this.app.log.info({ action: 'first_comment', platform: 'mastodon', statusId: status.id, outcome: 'success' });
+      } catch (err) {
+        this.app.log.warn({ action: 'first_comment', platform: 'mastodon', statusId: status.id, outcome: 'failure', err: err.message });
+      }
+    }
+
     return { id: status.id, url: status.url };
   }
 }

+ 4 - 4
services/scheduler/index.js

@@ -31,7 +31,7 @@ let redis;
 async function processPostJob(job) {
   // destinations: [{ platform, accountId?, imageUrl?, videoUrl?, link? }]
   // Falls back to legacy { platforms: string[] } format
-  const { postId, content, destinations, platforms, media = [] } = job.data;
+  const { postId, content, destinations, platforms, media = [], firstComment } = job.data;
   // Ensure every post has a stable ID for analytics tracking
   const effectivePostId = postId || randomUUID();
 
@@ -60,7 +60,7 @@ async function processPostJob(job) {
       continue;
     }
     try {
-      const response = await axios.post(`${serviceUrl}/post`, { content, accountId, imageUrl, videoUrl, link, media }, { timeout: 30000 });
+      const response = await axios.post(`${serviceUrl}/post`, { content, accountId, imageUrl, videoUrl, link, media, firstComment: firstComment?.trim() || undefined }, { timeout: 30000 });
       results[resultKey] = { success: true, ...response.data.result };
     } catch (err) {
       results[resultKey] = { success: false, error: err.message };
@@ -125,7 +125,7 @@ app.get('/health', async () => ({ status: 'ok', service: 'scheduler' }));
 // Body: { content, scheduledAt, destinations: [{ platform, accountId?, imageUrl?, videoUrl?, link? }] }
 // Legacy { platforms: string[] } still accepted for backwards compatibility.
 app.post('/schedule', async (request, reply) => {
-  const { postId, content, destinations, platforms, scheduledAt, media = [] } = request.body;
+  const { postId, content, destinations, platforms, scheduledAt, media = [], firstComment } = request.body;
 
   const destList = destinations || (platforms || []).map((p) => ({ platform: p }));
 
@@ -140,7 +140,7 @@ app.post('/schedule', async (request, reply) => {
 
   const job = await postQueue.add(
     'scheduled-post',
-    { postId, content, destinations: destList, media },
+    { postId, content, destinations: destList, media, firstComment: firstComment?.trim() || undefined },
     { delay, attempts: 3, backoff: { type: 'exponential', delay: 60000 } }
   );
 

+ 4 - 0
ui/src/locales/en.ts

@@ -182,6 +182,10 @@ export default {
     captionGenerate: '✨ Generate caption',
     captionGenerating: 'Generating caption…',
     captionError: 'Caption generation failed',
+
+    firstCommentToggle: 'First Comment',
+    firstCommentPlaceholder: 'Add a first comment (hashtags, links, extra context)…',
+    firstCommentHint: 'Supported on Instagram, Facebook, Mastodon, and Bluesky.',
   },
 
   scheduler: {

+ 4 - 0
ui/src/locales/tr.ts

@@ -182,6 +182,10 @@ export default {
     captionGenerate: '✨ Açıklama oluştur',
     captionGenerating: 'Açıklama oluşturuluyor…',
     captionError: 'Açıklama oluşturma başarısız',
+
+    firstCommentToggle: 'İlk Yorum',
+    firstCommentPlaceholder: 'İlk yorum ekle (hashtagler, bağlantılar, ek bilgi)…',
+    firstCommentHint: 'Instagram, Facebook, Mastodon ve Bluesky\'de desteklenir.',
   },
 
   scheduler: {

+ 10 - 1
ui/src/stores/compose.ts

@@ -30,6 +30,7 @@ export interface Draft {
   mediaUrl: string
   scheduledAt: string
   destinations: Destination[]
+  firstComment?: string
   createdAt: string
   updatedAt: string
 }
@@ -38,6 +39,7 @@ export const useComposeStore = defineStore('compose', () => {
   const content = ref('')
   const mediaUrl = ref('')
   const scheduledAt = ref('')
+  const firstComment = ref('')
   const destinations = ref<Destination[]>([])
   const sending = ref(false)
   const savingDraft = ref(false)
@@ -119,6 +121,7 @@ export const useComposeStore = defineStore('compose', () => {
     content.value = ''
     mediaUrl.value = ''
     scheduledAt.value = ''
+    firstComment.value = ''
     draftId.value = null
     destinations.value.forEach((d) => { d.selected = false })
     lastResult.value = null
@@ -129,6 +132,7 @@ export const useComposeStore = defineStore('compose', () => {
     content.value = draft.content || ''
     mediaUrl.value = draft.mediaUrl || ''
     scheduledAt.value = draft.scheduledAt || ''
+    firstComment.value = draft.firstComment || ''
     if (draft.destinations?.length) {
       destinations.value.forEach((d) => {
         const saved = draft.destinations.find((s) => s.key === d.key)
@@ -144,6 +148,7 @@ export const useComposeStore = defineStore('compose', () => {
         content: content.value,
         mediaUrl: mediaUrl.value,
         scheduledAt: scheduledAt.value,
+        firstComment: firstComment.value || undefined,
         destinations: destinations.value.map(({ key, platform, accountId, label, color, picture, selected }) => ({
           key, platform, accountId, label, color, picture, selected,
         })),
@@ -176,6 +181,8 @@ export const useComposeStore = defineStore('compose', () => {
         ...(mediaUrl.value.trim() && { imageUrl: mediaUrl.value.trim() }),
       }))
 
+      const firstCommentValue = firstComment.value.trim() || undefined
+
       if (scheduledAt.value) {
         const utcScheduledAt = timezone
           ? naiveDatetimeToUtc(scheduledAt.value, timezone)
@@ -184,11 +191,13 @@ export const useComposeStore = defineStore('compose', () => {
           content: content.value,
           scheduledAt: utcScheduledAt,
           destinations: destPayload,
+          ...(firstCommentValue && { firstComment: firstCommentValue }),
         })
       } else {
         await axios.post('/api/post', {
           content: content.value,
           destinations: destPayload,
+          ...(firstCommentValue && { firstComment: firstCommentValue }),
         })
       }
 
@@ -202,7 +211,7 @@ export const useComposeStore = defineStore('compose', () => {
   }
 
   return {
-    content, mediaUrl, scheduledAt, destinations, sending, savingDraft, draftId, lastResult,
+    content, mediaUrl, scheduledAt, firstComment, destinations, sending, savingDraft, draftId, lastResult,
     selectedDestinations, activeCharLimit,
     charLimit, isOverLimit,
     initDestinations, toggleDestination, reset, loadDraft, saveDraft, post,

+ 29 - 0
ui/src/views/Compose.vue

@@ -300,6 +300,34 @@
           </div>
         </div>
 
+        <!-- First Comment -->
+        <div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
+          <button
+            @click="firstCommentOpen = !firstCommentOpen"
+            class="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-300 hover:text-gray-100 hover:bg-gray-800/50 transition-colors"
+          >
+            <div class="flex items-center gap-2">
+              <svg class="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+                <path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
+              </svg>
+              <span>{{ $t('compose.firstCommentToggle') }}</span>
+              <span v-if="composeStore.firstComment.trim()" class="w-2 h-2 rounded-full bg-blue-400 inline-block" />
+            </div>
+            <svg class="w-4 h-4 text-gray-600 transition-transform" :class="firstCommentOpen ? 'rotate-180' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+              <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
+            </svg>
+          </button>
+          <div v-if="firstCommentOpen" class="border-t border-gray-800 p-4 space-y-2">
+            <textarea
+              v-model="composeStore.firstComment"
+              :placeholder="$t('compose.firstCommentPlaceholder')"
+              rows="3"
+              class="w-full bg-gray-800 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 resize-none focus:outline-none focus:border-blue-500 text-sm leading-relaxed p-3"
+            ></textarea>
+            <p class="text-xs text-gray-500">{{ $t('compose.firstCommentHint') }}</p>
+          </div>
+        </div>
+
         <!-- Instagram warning -->
         <div v-if="igSelectedWithoutMedia" class="flex items-center gap-2 bg-amber-900/30 border border-amber-700/50 rounded-xl px-4 py-2.5 text-xs text-amber-300">
           <svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
@@ -429,6 +457,7 @@ const uploadError = ref('')
 const mediaLoadError = ref(false)
 const activePreviewKey = ref('')
 const draftSavedBanner = ref(false)
+const firstCommentOpen = ref(false)
 
 onMounted(async () => {
   await Promise.all([