Skill: loui1979/facebook-page-manager-1-0-0 (version 1.0.0)

--- name: facebook-page description: Manage Facebook Pages via Meta Graph API. Post content (text, photos, links), list posts, manage comments (list/reply/hide/delete). Use when user wants to publish to Facebook Page, check Page posts, or handle comments. --- # Facebook Page Skill để quản lý Facebook Page qua Meta Graph API. ## Chức năng - List các Page mà user quản lý - Đăng bài (text, ảnh, link) - List bài đăng của Page - List/reply/hide/delete comment ## Setup (một lần) ### 1. Tạo Meta App 1. Vào https://developers.facebook.com/apps/ → Create App 2. Chọn **"Other"** → **"Business"** (hoặc Consumer tuỳ use-case) 3. Điền tên app, email 4. Vào **App settings > Basic**: lấy **App ID** và **App Secret** ### 2. Cấu hình OAuth 1. Vào **Add Product** → thêm **Facebook Login** 2. Trong **Facebook Login > Settings**: - Valid OAuth Redirect URIs: để trống (dùng manual code flow) 3. Vào **App Roles > Roles** → thêm account làm Admin/Developer ### 3. Cấu hình .env ```bash cd skills/facebook-page cp .env.example .env # Edit .env với App ID và Secret ``` ### 4. Cài dependencies và lấy token ```bash cd scripts npm install node auth.js login ``` Script sẽ: 1. In ra URL để user mở browser, đăng nhập, approve permissions 2. User copy URL sau khi approve (chứa `code=...`) 3. Paste URL vào terminal 4. Script exchange code → long-lived token → page tokens 5. Lưu tokens vào `~/.config/fbpage/tokens.json` ## Commands ### List pages ```bash node cli.js pages ``` ### Đăng bài text ```bash node cli.js post create --page PAGE_ID --message "Hello world" ``` ### Đăng bài có ảnh ```bash node cli.js post create --page PAGE_ID --message "Caption" --photo /path/to/image.jpg ``` ### Đăng bài có link ```bash node cli.js post create --page PAGE_ID --message "Check this out" --link "https://example.com" ``` ### List posts ```bash node cli.js post list --page PAGE_ID --limit 10 ``` ### List comments của post ```bash node cli.js comments list --post POST_ID ``` ### Reply comment ```bash node cli.js comments reply --comment COMMENT_ID --message "Thanks!" ``` ### Hide comment ```bash node cli.js comments hide --comment COMMENT_ID ``` ### Delete comment ```bash node cli.js comments delete --comment COMMENT_ID ``` ## Permissions cần thiết - `pages_show_list` - list pages - `pages_read_engagement` - đọc posts/comments - `pages_manage_posts` - đăng/sửa/xoá bài - `pages_manage_engagement` - quản lý comments ## Lưu ý - Token Page không hết hạn (nếu lấy từ long-lived user token) - Không log/print token ra output - App ở Testing mode chỉ hoạt động với accounts trong Roles

path: env_example.md
content: # Facebook Page Skill Configuration
# Copy this file to .env and fill in your values

# Meta App credentials (from developers.facebook.com)
META_APP_ID=your_app_id_here
META_APP_SECRET=your_app_secret_here


path: references/graph-api.md
content: # Graph API Reference

## Base URL
```
https://graph.facebook.com/v21.0
```

## Authentication
Tất cả request cần `access_token` parameter.

### Token Types
- **User Token**: Lấy từ OAuth login, dùng để lấy Page tokens
- **Page Token**: Dùng cho các thao tác trên Page cụ thể

### Token Flow
1. User login → short-lived user token (~1h)
2. Exchange → long-lived user token (~60 days)
3. Get /me/accounts → Page tokens (không hết hạn nếu từ long-lived user token)

## Endpoints

### Pages
```
GET /me/accounts
  ?fields=id,name,access_token
```
Trả về list Pages mà user quản lý.

### Posts

#### List posts
```
GET /{page-id}/posts
  ?fields=id,message,created_time,permalink_url,shares,likes.summary(true),comments.summary(true)
  &limit=10
```

#### Create text post
```
POST /{page-id}/feed
  message=Hello world
```

#### Create photo post
```
POST /{page-id}/photos
  message=Caption
  source=<file>
```

#### Create link post
```
POST /{page-id}/feed
  message=Check this
  link=https://example.com
```

#### Delete post
```
DELETE /{post-id}
```

### Comments

#### List comments
```
GET /{post-id}/comments
  ?fields=id,message,from,created_time,like_count,is_hidden
  &limit=25
```

#### Reply to comment
```
POST /{comment-id}/comments
  message=Reply text
```

#### Hide/unhide comment
```
POST /{comment-id}
  is_hidden=true|false
```

#### Delete comment
```
DELETE /{comment-id}
```

## Permissions

| Permission | Mô tả |
|------------|-------|
| pages_show_list | Xem list Pages user quản lý |
| pages_read_engagement | Đọc posts, comments, reactions |
| pages_manage_posts | Tạo/sửa/xoá posts |
| pages_manage_engagement | Quản lý comments (hide/delete/reply) |

## Rate Limits
- ~200 calls/user/hour cho hầu hết endpoints
- Batch requests để giảm số calls
- Check header `X-App-Usage` để monitor

## Error Codes
| Code | Mô tả |
|------|-------|
| 190 | Token hết hạn hoặc invalid |
| 200 | Permission denied |
| 10 | Permission chưa được cấp |
| 368 | Spam/policy violation |


path: scripts/auth.js
content: #!/usr/bin/env node
/**
 * Facebook Page OAuth Authentication
 * Manual code flow - user copies redirect URL containing code
 */

import { createInterface } from "readline";
import { readFileSync, writeFileSync, chmodSync, existsSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { config } from "dotenv";

const __dirname = dirname(fileURLToPath(import.meta.url));
const SKILL_DIR = join(__dirname, "..");
const ENV_FILE = join(SKILL_DIR, ".env");
const TOKENS_FILE = join(SKILL_DIR, "tokens.json");

// Load .env from skill directory
config({ path: ENV_FILE });

const GRAPH_API_VERSION = "v21.0";
const GRAPH_API_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`;

const SCOPES = [
  "pages_show_list",
  "pages_read_engagement",
  "pages_manage_posts",
  "public_profile",
];

function loadConfig() {
  const appId = process.env.META_APP_ID;
  const appSecret = process.env.META_APP_SECRET;
  
  if (!appId || !appSecret) {
    console.error("Error: META_APP_ID and META_APP_SECRET not found");
    console.error(`Create .env file at: ${ENV_FILE}`);
    console.error("Copy from .env.example and fill in your values");
    process.exit(1);
  }
  
  return { app_id: appId, app_secret: appSecret };
}

function saveTokens(tokens) {
  writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
  chmodSync(TOKENS_FILE, 0o600);
  console.log(`Tokens saved to ${TOKENS_FILE}`);
}

function loadTokens() {
  if (!existsSync(TOKENS_FILE)) return null;
  return JSON.parse(readFileSync(TOKENS_FILE, "utf-8"));
}

function getLoginUrl(config) {
  const params = new URLSearchParams({
    client_id: config.app_id,
    redirect_uri: "https://localhost/",
    scope: SCOPES.join(","),
    response_type: "code",
  });
  return `https://www.facebook.com/${GRAPH_API_VERSION}/dialog/oauth?${params}`;
}

async function exchangeCodeForToken(code, config) {
  const params = new URLSearchParams({
    client_id: config.app_id,
    client_secret: config.app_secret,
    redirect_uri: "https://localhost/",
    code,
  });
  
  const resp = await fetch(`${GRAPH_API_BASE}/oauth/access_token?${params}`);
  if (!resp.ok) {
    const err = await resp.json();
    throw new Error(JSON.stringify(err));
  }
  return resp.json();
}

async function getLongLivedToken(shortToken, config) {
  const params = new URLSearchParams({
    grant_type: "fb_exchange_token",
    client_id: config.app_id,
    client_secret: config.app_secret,
    fb_exchange_token: shortToken,
  });
  
  const resp = await fetch(`${GRAPH_API_BASE}/oauth/access_token?${params}`);
  if (!resp.ok) {
    const err = await resp.json();
    throw new Error(JSON.stringify(err));
  }
  return resp.json();
}

async function getPageTokens(userToken) {
  const params = new URLSearchParams({
    access_token: userToken,
    fields: "id,name,access_token",
  });
  
  const resp = await fetch(`${GRAPH_API_BASE}/me/accounts?${params}`);
  if (!resp.ok) {
    const err = await resp.json();
    throw new Error(JSON.stringify(err));
  }
  const data = await resp.json();
  return data.data || [];
}

function extractCodeFromUrl(url) {
  try {
    const parsed = new URL(url);
    const code = parsed.searchParams.get("code");
    if (code) return code;
    
    // Check fragment
    const fragmentParams = new URLSearchParams(parsed.hash.slice(1));
    return fragmentParams.get("code");
  } catch {
    return null;
  }
}

function prompt(question) {
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      rl.close();
      resolve(answer.trim());
    });
  });
}

async function cmdLogin() {
  const config = loadConfig();
  const loginUrl = getLoginUrl(config);
  
  console.log("\n=== Facebook Page OAuth Login ===\n");
  console.log("1. Open this URL in your browser:\n");
  console.log(loginUrl);
  console.log("\n2. Log in and approve the permissions");
  console.log("3. After approval, you'll be redirected to a URL starting with:");
  console.log("   https://localhost/?code=...");
  console.log("   (Browser sẽ báo lỗi không load được - đó là bình thường!)");
  console.log("\n4. Copy the ENTIRE URL from your browser address bar and paste it below:\n");
  
  const redirectUrl = await prompt("Paste URL here: ");
  
  const code = extractCodeFromUrl(redirectUrl);
  if (!code) {
    console.error("Error: Could not extract code from URL");
    process.exit(1);
  }
  
  console.log("\nExchanging code for token...");
  const tokenResp = await exchangeCodeForToken(code, config);
  const shortToken = tokenResp.access_token;
  
  console.log("Getting long-lived token...");
  const longLivedResp = await getLongLivedToken(shortToken, config);
  const userToken = longLivedResp.access_token;
  
  console.log("Fetching Page tokens...");
  const pages = await getPageTokens(userToken);
  
  if (!pages.length) {
    console.log("Warning: No Pages found. Make sure you have admin access to at least one Page.");
  }
  
  const tokens = {
    user_token: userToken,
    pages: Object.fromEntries(
      pages.map((p) => [p.id, { name: p.name, [REDACTED_SECRET] }])
    ),
  };
  
  saveTokens(tokens);
  
  console.log(`\nSuccess! Found ${pages.length} Page(s):`);
  for (const page of pages) {
    console.log(`  - ${page.name} (ID: ${page.id})`);
  }
}

function cmdStatus() {
  const tokens = loadTokens();
  if (!tokens) {
    console.log("No tokens found. Run: node auth.js login");
    return;
  }
  
  console.log(`User [REDACTED_SECRET] ? "Present" : "Missing"}`);
  console.log(`Pages: ${Object.keys(tokens.pages || {}).length}`);
  for (const [pageId, pageInfo] of Object.entries(tokens.pages || {})) {
    console.log(`  - ${pageInfo.name} (ID: ${pageId})`);
  }
}

async function cmdRefresh() {
  const config = loadConfig();
  const tokens = loadTokens();
  
  if (!tokens?.user_token) {
    console.log("No user token found. Run: node auth.js login");
    return;
  }
  
  console.log("Refreshing page tokens...");
  const pages = await getPageTokens(tokens.user_token);
  
  tokens.pages = Object.fromEntries(
    pages.map((p) => [p.id, { name: p.name, [REDACTED_SECRET] }])
  );
  saveTokens(tokens);
  
  console.log(`Refreshed ${pages.length} Page token(s)`);
}

// Main
const cmd = process.argv[2];

switch (cmd) {
  case "login":
    cmdLogin();
    break;
  case "status":
    cmdStatus();
    break;
  case "refresh":
    cmdRefresh();
    break;
  default:
    console.log("Usage: node auth.js <command>");
    console.log("Commands: login, status, refresh");
}


path: scripts/cli.js
content: #!/usr/bin/env node
/**
 * Facebook Page CLI
 * Commands for managing Facebook Pages via Graph API
 */

import { program } from "commander";
import { readFileSync, existsSync } from "fs";
import { dirname, join, basename } from "path";
import { fileURLToPath } from "url";
import { config } from "dotenv";
import { Blob } from "buffer";

const __dirname = dirname(fileURLToPath(import.meta.url));
const SKILL_DIR = join(__dirname, "..");
const ENV_FILE = join(SKILL_DIR, ".env");
const TOKENS_FILE = join(SKILL_DIR, "tokens.json");

// Load .env from skill directory
config({ path: ENV_FILE });

const GRAPH_API_VERSION = "v21.0";
const GRAPH_API_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`;

function loadTokens() {
  if (!existsSync(TOKENS_FILE)) {
    console.error(`Error: No tokens found at ${TOKENS_FILE}`);
    console.error("Run: node auth.js login");
    process.exit(1);
  }
  return JSON.parse(readFileSync(TOKENS_FILE, "utf-8"));
}

function getPageToken(tokens, pageId) {
  const pages = tokens.pages || {};
  if (!pages[pageId]) {
    console.error(`Error: Page ${pageId} not found in tokens`);
    console.error("Available pages:");
    for (const [pid, pinfo] of Object.entries(pages)) {
      console.log(`  ${pid}: ${pinfo.name}`);
    }
    process.exit(1);
  }
  return pages[pageId].token;
}

async function apiGet(endpoint, token, params = {}) {
  const url = new URL(`${GRAPH_API_BASE}/${endpoint}`);
  url.searchParams.set("access_token", token);
  for (const [k, v] of Object.entries(params)) {
    url.searchParams.set(k, v);
  }
  
  const resp = await fetch(url);
  const data = await resp.json();
  if (!resp.ok) {
    console.error(`API Error: ${resp.status}`);
    console.error(JSON.stringify(data, null, 2));
    process.exit(1);
  }
  return data;
}

async function apiPost(endpoint, token, body = {}) {
  const url = new URL(`${GRAPH_API_BASE}/${endpoint}`);
  url.searchParams.set("access_token", token);
  
  const formData = new URLSearchParams();
  for (const [k, v] of Object.entries(body)) {
    formData.set(k, v);
  }
  
  const resp = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: formData,
  });
  
  const data = await resp.json();
  if (!resp.ok) {
    console.error(`API Error: ${resp.status}`);
    console.error(JSON.stringify(data, null, 2));
    process.exit(1);
  }
  return data;
}

async function apiPostMultipart(endpoint, token, fields, filePath) {
  const url = new URL(`${GRAPH_API_BASE}/${endpoint}`);
  url.searchParams.set("access_token", token);
  
  const fileBuffer = readFileSync(filePath);
  const formData = new FormData();
  
  for (const [k, v] of Object.entries(fields)) {
    formData.set(k, v);
  }
  
  formData.set("source", new Blob([fileBuffer]), basename(filePath));
  
  const resp = await fetch(url, {
    method: "POST",
    body: formData,
  });
  
  const data = await resp.json();
  if (!resp.ok) {
    console.error(`API Error: ${resp.status}`);
    console.error(JSON.stringify(data, null, 2));
    process.exit(1);
  }
  return data;
}

async function apiDelete(endpoint, token) {
  const url = new URL(`${GRAPH_API_BASE}/${endpoint}`);
  url.searchParams.set("access_token", token);
  
  const resp = await fetch(url, { method: "DELETE" });
  const data = await resp.json();
  if (!resp.ok) {
    console.error(`API Error: ${resp.status}`);
    console.error(JSON.stringify(data, null, 2));
    process.exit(1);
  }
  return data;
}

// ============ PAGES ============

program
  .command("pages")
  .description("List pages you manage")
  .action(() => {
    const tokens = loadTokens();
    const pages = tokens.pages || {};
    
    if (!Object.keys(pages).length) {
      console.log("No pages found");
      return;
    }
    
    console.log(`${"ID".padEnd(20)} ${"Name".padEnd(40)}`);
    console.log("-".repeat(60));
    for (const [pageId, pageInfo] of Object.entries(pages)) {
      console.log(`${pageId.padEnd(20)} ${pageInfo.name.padEnd(40)}`);
    }
  });

// ============ POSTS ============

const postCmd = program.command("post").description("Post operations");

postCmd
  .command("list")
  .description("List posts from a page")
  .requiredOption("--page <id>", "Page ID")
  .option("--limit <n>", "Number of posts", "10")
  .action(async (opts) => {
    const tokens = loadTokens();
    const pageToken = getPageToken(tokens, opts.page);
    
    const result = await apiGet(`${opts.page}/posts`, pageToken, {
      fields: "id,message,created_time,permalink_url,shares,likes.summary(true),comments.summary(true)",
      limit: opts.limit,
    });
    
    const posts = result.data || [];
    if (!posts.length) {
      console.log("No posts found");
      return;
    }
    
    for (const post of posts) {
      console.log(`\n${"=".repeat(60)}`);
      console.log(`ID: ${post.id}`);
      console.log(`Date: ${post.created_time || "N/A"}`);
      console.log(`Message: ${(post.message || "(no text)").slice(0, 100)}...`);
      const likes = post.likes?.summary?.total_count || 0;
      const comments = post.comments?.summary?.total_count || 0;
      const shares = post.shares?.count || 0;
      console.log(`Likes: ${likes} | Comments: ${comments} | Shares: ${shares}`);
      console.log(`URL: ${post.permalink_url || "N/A"}`);
    }
  });

postCmd
  .command("create")
  .description("Create a new post")
  .requiredOption("--page <id>", "Page ID")
  .option("--message <text>", "Post message")
  .option("--photo <path>", "Path to photo file")
  .option("--link <url>", "URL to share")
  .action(async (opts) => {
    const tokens = loadTokens();
    const pageToken = getPageToken(tokens, opts.page);
    
    let result;
    
    if (opts.photo) {
      // Photo post
      const fields = {};
      if (opts.message) fields.message = opts.message;
      result = await apiPostMultipart(`${opts.page}/photos`, pageToken, fields, opts.photo);
    } else if (opts.link) {
      // Link post
      const body = { link: opts.link };
      if (opts.message) body.message = opts.message;
      result = await apiPost(`${opts.page}/feed`, pageToken, body);
    } else {
      // Text post
      if (!opts.message) {
        console.error("Error: --message is required for text posts");
        process.exit(1);
      }
      result = await apiPost(`${opts.page}/feed`, pageToken, { message: opts.message });
    }
    
    console.log(`Post created! ID: ${result.id || result.post_id}`);
  });

postCmd
  .command("delete")
  .description("Delete a post")
  .requiredOption("--page <id>", "Page ID")
  .requiredOption("--post <id>", "Post ID")
  .action(async (opts) => {
    const tokens = loadTokens();
    const pageToken = getPageToken(tokens, opts.page);
    
    const result = await apiDelete(opts.post, pageToken);
    if (result.success) {
      console.log("Post deleted successfully");
    } else {
      console.log("Failed to delete post");
    }
  });

// ============ COMMENTS ============

const commentsCmd = program.command("comments").description("Comment operations");

commentsCmd
  .command("list")
  .description("List comments on a post")
  .requiredOption("--post <id>", "Post ID")
  .option("--limit <n>", "Number of comments", "25")
  .action(async (opts) => {
    const tokens = loadTokens();
    const userToken = tokens.user_token;
    
    const result = await apiGet(`${opts.post}/comments`, userToken, {
      fields: "id,message,from,created_time,like_count,is_hidden",
      limit: opts.limit,
    });
    
    const comments = result.data || [];
    if (!comments.length) {
      console.log("No comments found");
      return;
    }
    
    for (const comment of comments) {
      const hidden = comment.is_hidden ? " [HIDDEN]" : "";
      console.log(`\n${"-".repeat(40)}`);
      console.log(`ID: ${comment.id}${hidden}`);
      console.log(`From: ${comment.from?.name || "Unknown"}`);
      console.log(`Date: ${comment.created_time || "N/A"}`);
      console.log(`Likes: ${comment.like_count || 0}`);
      console.log(`Message: ${comment.message || ""}`);
    }
  });

commentsCmd
  .command("reply")
  .description("Reply to a comment")
  .requiredOption("--comment <id>", "Comment ID")
  .requiredOption("--message <text>", "Reply message")
  .action(async (opts) => {
    const tokens = loadTokens();
    const userToken = tokens.user_token;
    
    const result = await apiPost(`${opts.comment}/comments`, userToken, {
      message: opts.message,
    });
    console.log(`Reply posted! ID: ${result.id}`);
  });

commentsCmd
  .command("hide")
  .description("Hide a comment")
  .requiredOption("--comment <id>", "Comment ID")
  .action(async (opts) => {
    const tokens = loadTokens();
    const userToken = tokens.user_token;
    
    const result = await apiPost(opts.comment, userToken, { is_hidden: "true" });
    if (result.success) {
      console.log("Comment hidden successfully");
    } else {
      console.log("Failed to hide comment");
    }
  });

commentsCmd
  .command("unhide")
  .description("Unhide a comment")
  .requiredOption("--comment <id>", "Comment ID")
  .action(async (opts) => {
    const tokens = loadTokens();
    const userToken = tokens.user_token;
    
    const result = await apiPost(opts.comment, userToken, { is_hidden: "false" });
    if (result.success) {
      console.log("Comment unhidden successfully");
    } else {
      console.log("Failed to unhide comment");
    }
  });

commentsCmd
  .command("delete")
  .description("Delete a comment")
  .requiredOption("--comment <id>", "Comment ID")
  .action(async (opts) => {
    const tokens = loadTokens();
    const userToken = tokens.user_token;
    
    const result = await apiDelete(opts.comment, userToken);
    if (result.success) {
      console.log("Comment deleted successfully");
    } else {
      console.log("Failed to delete comment");
    }
  });

program.parse();


path: scripts/fb_post.js
content: #!/usr/bin/env node
/**
 * Post to Facebook Page using existing tokens.json
 * Usage:
 *   node fb_post.js --page <PAGE_ID> --caption-file <path> [--image <path>]
 */

import { readFileSync, existsSync } from "fs";
import { dirname, join, basename } from "path";
import { fileURLToPath } from "url";
import { Blob } from "buffer";

const __dirname = dirname(fileURLToPath(import.meta.url));
const SKILL_DIR = join(__dirname, "..");
const TOKENS_FILE = join(SKILL_DIR, "tokens.json");

const GRAPH_API_VERSION = "v21.0";
const FB_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`;

function arg(name) {
  const idx = process.argv.indexOf(name);
  if (idx === -1) return null;
  return process.argv[idx + 1] || null;
}

async function postText(pageId, pageToken, message) {
  const url = new URL(`${FB_BASE}/${pageId}/feed`);
  url.searchParams.set("access_token", pageToken);
  const body = new URLSearchParams({ message });
  const resp = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });
  const data = await resp.json();
  if (!resp.ok) throw new Error(`FB error: ${JSON.stringify(data)}`);
  return data;
}

async function postPhoto(pageId, pageToken, imagePath, caption) {
  const url = new URL(`${FB_BASE}/${pageId}/photos`);
  url.searchParams.set("access_token", pageToken);

  const fileBuf = readFileSync(imagePath);
  const form = new FormData();
  form.set("message", caption);
  form.set("source", new Blob([fileBuf]), basename(imagePath));

  const resp = await fetch(url, { method: "POST", body: form });
  const data = await resp.json();
  if (!resp.ok) throw new Error(`FB error: ${JSON.stringify(data)}`);
  return data;
}

async function main() {
  const pageId = arg("--page");
  const captionFile = arg("--caption-file");
  const imagePath = arg("--image");

  if (!pageId || !captionFile) {
    console.error("Usage: node fb_post.js --page <PAGE_ID> --caption-file <path> [--image <path>]");
    process.exit(2);
  }

  if (!existsSync(TOKENS_FILE)) throw new Error(`Missing tokens: ${TOKENS_FILE}`);
  const tokens = JSON.parse(readFileSync(TOKENS_FILE, "utf-8"));
  const pageToken = tokens.pages?.[pageId]?.token;
  if (!pageToken) throw new Error(`No token for page ${pageId}`);

  const caption = readFileSync(captionFile, "utf-8").trim();
  if (!caption) throw new Error("Empty caption");

  const result = imagePath ? await postPhoto(pageId, pageToken, imagePath, caption) : await postText(pageId, pageToken, caption);

  console.log(JSON.stringify({ ok: true, pageId, postId: result.id || result.post_id, usedPhoto: Boolean(imagePath) }));
}

main().catch((e) => {
  console.error(JSON.stringify({ ok: false, error: String(e) }));
  process.exit(1);
});


path: scripts/package-lock.json
content: {
  "name": "facebook-page-skill",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "facebook-page-skill",
      "version": "1.0.0",
      "dependencies": {
        "commander": "^12.0.0",
        "dotenv": "^16.4.0"
      },
      "bin": {
        "fbpage": "cli.js"
      }
    },
    "node_modules/commander": {
      "version": "12.1.0",
      "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/dotenv": {
      "version": "16.6.1",
      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://dotenvx.com"
      }
    }
  }
}


path: scripts/package.json
content: {
  "name": "facebook-page-skill",
  "version": "1.0.0",
  "description": "Facebook Page management CLI",
  "type": "module",
  "main": "cli.js",
  "bin": {
    "fbpage": "./cli.js"
  },
  "scripts": {
    "auth": "node auth.js",
    "cli": "node cli.js"
  },
  "dependencies": {
    "commander": "^12.0.0",
    "dotenv": "^16.4.0"
  }
}


path: scripts/x_digest_collect.js
content: #!/usr/bin/env node
/**
 * Collect candidate tweets for Clawdbot/Moltbot digest.
 * Outputs JSON: {tweets:[...], pickedImage:{tweetUrl, imageUrl, localPath}}
 *
 * Requires env: AUTH_TOKEN, CT0
 */

import { execFileSync } from "child_process";
import { mkdirSync, writeFileSync } from "fs";
import { join } from "path";

function requireEnv(name) {
  const v = process.env[name];
  if (!v) throw new Error(`Missing env ${name}`);
  return v;
}

function birdJson(args) {
  const out = execFileSync("bird", args, {
    env: {
      ...process.env,
      AUTH_TOKEN: requireEnv("AUTH_TOKEN"),
      CT0: requireEnv("CT0"),
    },
    stdio: ["ignore", "pipe", "pipe"],
    encoding: "utf-8",
  });
  return JSON.parse(out);
}

function search(query, n = 12) {
  return birdJson(["search", query, "-n", String(n), "--json", "--plain"]);
}

function tweetUrl(t) {
  const u = t.author?.username;
  return u ? `https://x.com/${u}/status/${t.id}` : `https://x.com/i/web/status/${t.id}`;
}

function score(t) {
  const text = (t.text || "").toLowerCase();
  let s = 0;
  const ts = Date.parse(t.createdAt || "");
  if (!Number.isNaN(ts)) {
    const ageHrs = (Date.now() - ts) / 36e5;
    if (ageHrs < 12) s += 8;
    else if (ageHrs < 24) s += 5;
    else if (ageHrs < 72) s += 2;
  }
  const keywords = [
    "use case",
    "use-case",
    "workflow",
    "automation",
    "automate",
    "agent",
    "email",
    "calendar",
    "telegram",
    "self-host",
    "self host",
    "setup",
    "guide",
    "tutorial",
    "workers",
    "cloudflare",
    "r2",
    "security",
    "rce",
  ];
  for (const k of keywords) if (text.includes(k)) s += 2;
  if (t.media?.some((m) => m.type === "photo" && m.url)) s += 6;
  s += Math.min(6, (t.likeCount || 0) / 50);
  s += Math.min(4, (t.retweetCount || 0) / 20);
  const u = (t.author?.username || "").toLowerCase();
  if (u.includes("steipete") || u.includes("clawdbot") || u.includes("moltbot")) s += 4;
  return s;
}

async function download(url) {
  const resp = await fetch(url);
  if (!resp.ok) throw new Error(`Failed to download image: ${resp.status}`);
  const buf = Buffer.from(await resp.arrayBuffer());
  const dir = "/tmp/clawdbot-x-digest";
  mkdirSync(dir, { recursive: true });
  const file = join(dir, `img_${Date.now()}.jpg`);
  writeFileSync(file, buf);
  return file;
}

async function main() {
  const tweets = [...search("clawdbot", 15), ...search("moltbot", 15)];
  const top = [...tweets]
    .filter((t) => t?.id && t?.text)
    // Keep only tweets clearly about clawdbot/moltbot (search can be noisy)
    .filter((t) => {
      const txt = (t.text || "").toLowerCase();
      const au = (t.author?.username || "").toLowerCase();
      return txt.includes("clawdbot") || txt.includes("moltbot") || au.includes("clawdbot") || au.includes("moltbot") || au.includes("steipete");
    })
    .sort((a, b) => score(b) - score(a))
    .slice(0, 10)
    .map((t) => ({
      id: t.id,
      createdAt: t.createdAt,
      text: t.text,
      author: t.author,
      likeCount: t.likeCount,
      retweetCount: t.retweetCount,
      replyCount: t.replyCount,
      url: tweetUrl(t),
      media: t.media || [],
    }));

  const withPhoto = top.find((t) => t.media?.some((m) => m.type === "photo" && m.url));
  let pickedImage = null;
  if (withPhoto) {
    const imageUrl = withPhoto.media.find((m) => m.type === "photo" && m.url)?.url;
    if (imageUrl) {
      const localPath = await download(imageUrl);
      pickedImage = { tweetUrl: withPhoto.url, imageUrl, localPath };
    }
  }

  console.log(JSON.stringify({ tweets: top, pickedImage }));
}

main().catch((e) => {
  console.error(JSON.stringify({ error: String(e) }));
  process.exit(1);
});


path: scripts/x_digest_to_fb.js
content: #!/usr/bin/env node
/**
 * X -> Facebook Page digest poster
 * - Searches X for Clawdbot + Moltbot
 * - Prioritizes use-cases/automation posts
 * - Picks 1 tweet with an image (photo)
 * - Posts to a Facebook Page as a photo post with caption + links
 *
 * Requirements:
 * - X cookies in env: AUTH_TOKEN, CT0
 * - FB tokens.json at ../tokens.json (generated earlier)
 */

import { execFileSync } from "child_process";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const SKILL_DIR = join(__dirname, "..");
const TOKENS_FILE = join(SKILL_DIR, "tokens.json");

const GRAPH_API_VERSION = "v21.0";
const FB_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`;

function requireEnv(name) {
  const v = process.env[name];
  if (!v) throw new Error(`Missing env ${name}`);
  return v;
}

function loadFbTokens() {
  if (!existsSync(TOKENS_FILE)) {
    throw new Error(`Missing FB tokens file: ${TOKENS_FILE}. Run auth flow first.`);
  }
  return JSON.parse(readFileSync(TOKENS_FILE, "utf-8"));
}

function birdSearchJson(query, count = 12) {
  const out = execFileSync(
    "bird",
    ["search", query, "-n", String(count), "--json", "--plain"],
    {
      env: {
        ...process.env,
        AUTH_TOKEN: requireEnv("AUTH_TOKEN"),
        CT0: requireEnv("CT0"),
      },
      stdio: ["ignore", "pipe", "pipe"],
      encoding: "utf-8",
    }
  );
  return JSON.parse(out);
}

function scoreTweet(t) {
  const text = (t.text || "").toLowerCase();
  let score = 0;

  // prefer recency (createdAt is string; coarse heuristic via Date)
  const ts = Date.parse(t.createdAt || "");
  if (!Number.isNaN(ts)) {
    const ageHrs = (Date.now() - ts) / 36e5;
    if (ageHrs < 12) score += 8;
    else if (ageHrs < 24) score += 5;
    else if (ageHrs < 72) score += 2;
  }

  // prefer use-cases
  const keywords = [
    "use case",
    "use-case",
    "workflow",
    "automation",
    "automate",
    "agent",
    "email",
    "calendar",
    "telegram",
    "self-host",
    "self host",
    "setup",
    "guide",
    "tutorial",
    "workers",
    "cloudflare",
    "r2",
  ];
  for (const k of keywords) if (text.includes(k)) score += 2;

  // prefer images
  if (t.media?.some((m) => m.type === "photo" && m.url)) score += 6;

  // engagement signal
  score += Math.min(6, (t.likeCount || 0) / 50);
  score += Math.min(4, (t.retweetCount || 0) / 20);

  // prefer relevant authors
  const u = (t.author?.username || "").toLowerCase();
  if (u.includes("steipete") || u.includes("clawdbot") || u.includes("moltbot")) score += 4;

  return score;
}

function pickTop(tweets, n = 6) {
  return [...tweets]
    .filter((t) => t?.id && t?.text)
    .sort((a, b) => scoreTweet(b) - scoreTweet(a))
    .slice(0, n);
}

function tweetUrl(t) {
  const u = t.author?.username;
  return u ? `https://x.com/${u}/status/${t.id}` : `https://x.com/i/web/status/${t.id}`;
}

async function downloadToTmp(url) {
  const resp = await fetch(url);
  if (!resp.ok) throw new Error(`Failed to download image: ${resp.status}`);
  const buf = Buffer.from(await resp.arrayBuffer());
  const dir = "/tmp/clawdbot-x-digest";
  mkdirSync(dir, { recursive: true });
  const file = join(dir, `img_${Date.now()}.jpg`);
  writeFileSync(file, buf);
  return file;
}

async function fbUploadPhoto(pageId, pageToken, imagePath, caption) {
  const url = new URL(`${FB_BASE}/${pageId}/photos`);
  url.searchParams.set("access_token", pageToken);

  const fileBuf = readFileSync(imagePath);
  const form = new FormData();
  form.set("message", caption);
  form.set("source", new Blob([fileBuf]), "image.jpg");

  const resp = await fetch(url, { method: "POST", body: form });
  const data = await resp.json();
  if (!resp.ok) throw new Error(`FB error: ${JSON.stringify(data)}`);
  return data;
}

function buildCaption(topTweets) {
  const lines = [];
  const now = new Date();
  const hh = String(now.getHours()).padStart(2, "0");
  const mm = String(now.getMinutes()).padStart(2, "0");
  const dd = String(now.getDate()).padStart(2, "0");
  const MM = String(now.getMonth() + 1).padStart(2, "0");

  lines.push(`Tổng hợp nhanh tin X về Clawdbot/Moltbot (${hh}:${mm} ${dd}/${MM})`);
  lines.push("");
  lines.push("Ưu tiên: use-cases / automation / setup.");
  lines.push("");

  topTweets.forEach((t, i) => {
    const author = t.author?.username ? `@${t.author.username}` : "";
    const text = (t.text || "").replace(/\s+/g, " ").trim();
    const snippet = text.length > 160 ? text.slice(0, 157) + "…" : text;
    lines.push(`${i + 1}) ${author}: ${snippet}`);
    lines.push(`   ${tweetUrl(t)}`);
  });

  lines.push("");
  lines.push("Theo dõi thêm: clawd.bot | github.com/clawdbot/clawdbot");
  lines.push("#Clawdbot #Moltbot #AI #Automation #OpenSource");

  // FB caption max is high, but keep it compact
  return lines.join("\n").slice(0, 6000);
}

async function main() {
  const pageId = process.argv[2] || process.env.FB_PAGE_ID;
  if (!pageId) throw new Error("Missing page id. Pass as arg or set FB_PAGE_ID");

  const fbTokens = loadFbTokens();
  const pageInfo = fbTokens.pages?.[pageId];
  if (!pageInfo?.token) {
    throw new Error(`Page ${pageId} token not found in ${TOKENS_FILE}`);
  }

  // Search queries
  const q1 = "clawdbot";
  const q2 = "moltbot";
  const tweets = [
    ...birdSearchJson(q1, 12),
    ...birdSearchJson(q2, 12),
  ];

  const top = pickTop(tweets, 6);
  if (!top.length) throw new Error("No tweets found");

  // pick first with photo
  const withPhoto = top.find((t) => t.media?.some((m) => m.type === "photo" && m.url));
  const photoUrl = withPhoto?.media?.find((m) => m.type === "photo" && m.url)?.url;

  const caption = buildCaption(top);

  let result;
  if (photoUrl) {
    const imgPath = await downloadToTmp(photoUrl);
    result = await fbUploadPhoto(pageId, pageInfo.token, imgPath, caption);
  } else {
    // Fallback to text post
    const url = new URL(`${FB_BASE}/${pageId}/feed`);
    url.searchParams.set("access_token", pageInfo.token);
    const body = new URLSearchParams({ message: caption });
    const resp = await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body,
    });
    const data = await resp.json();
    if (!resp.ok) throw new Error(`FB error: ${JSON.stringify(data)}`);
    result = data;
  }

  console.log(JSON.stringify({ ok: true, pageId, postId: result.id || result.post_id, usedPhoto: Boolean(photoUrl) }));
}

main().catch((e) => {
  console.error(JSON.stringify({ ok: false, error: String(e) }));
  process.exit(1);
});


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)