diff --git a/marketing/facebook/COPY.md b/marketing/facebook/COPY.md index 98ad8df1..24cdad27 100644 --- a/marketing/facebook/COPY.md +++ b/marketing/facebook/COPY.md @@ -153,3 +153,57 @@ Válasz: ``` A költség két részből áll: egyszeri költségek (a hardver ára, konfigurációtól függően 80.000–400.000+ Ft, és a telepítési díj) és havi üzemeltetési díj (monitoring, támogatás, frissítések, mentés-felügyelet). A hardvert te vásárolod meg – a tiéd, örökre. Pontos árajánlatot az egyeztetés után adunk, mert az igények nagyon eltérőek lehetnek. Részletek: https://felhom.eu/gyik.html ``` + + +## 6. Az első bejegyzés (R-917 (c) — a Page első, kiemelt posztja) + +A §2 hosszabb leírásból rövidítve. **Minden állítás ma is ott van a `website/index.html`-en**, soronként +megjelölve. Magyarul, tegező hangnem, ár nélkül. **Emodzsi: egy sem** — a Felhom arculata nem használ +emodzsit (a weboldal gate-je 0-ra tartja), és egy első bemutatkozó poszton olcsóbbnak hatna, mint +amilyen a termék. **Hashtag: egy sem** — kettő is elférne, de semmilyen valódi keresést nem szolgálna +ki, és a poszt nem lesz tőlük megtalálhatóbb. + +A Facebook kb. három sor után elvágja a szöveget és „Továbbiak"-at ír ki, ezért **az első két sor +önmagában is elmondja a lényeget**, és nem bemutatkozással kezd. + +### 6.1 Rövid változat + + + + + + + + + +``` +A fotóid és a dokumentumaid a saját gépeden maradnak – nem egy nagy techcég szerverén. + +A Felhom egy otthoni szerver: mi telepítjük és üzemeltetjük, te csak használod. Minden éjjel mentés készül, és a távoli mentés kulcsát egyedül te ismered. + +Most zárt tesztet indítunk néhány magyar háztartással. Nem kötelez semmire: https://felhom.eu/kapcsolat +``` + +### 6.2 Közepes változat + + + + + + + + +``` +A fotóid, a papírjaid és a médiatárad a saját gépeden maradnak – nem egy nagy techcég szerverén. + +A Felhom egy otthoni szerver, amit mi telepítünk és üzemeltetünk. Te csak használod: +– a telefonod fotói maguktól feltöltődnek rá, +– a papírjaid kereshetővé válnak, +– a mappái ott vannak a Windows Intézőben és a Mac Finderben, +– 56 alkalmazás telepíthető pár kattintással, +– ha elmegy a net, otthon is eléred a fájljaidat. + +Minden éjjel mentés készül három helyre. A távoli mentés kulcsát egyedül te ismered. + +Most néhány magyar háztartással zárt tesztet indítunk. Nem kötelez semmire, először csak beszélgetünk: https://felhom.eu/kapcsolat +``` diff --git a/scripts/facebook/fb_probe.py b/scripts/facebook/fb_probe.py index 180ef52d..267a126f 100644 --- a/scripts/facebook/fb_probe.py +++ b/scripts/facebook/fb_probe.py @@ -25,6 +25,7 @@ Exit codes: 0 ok · 2 key refused · 3 Scenario A failed · 4 Scenario B failed · 8 a test object read back PUBLISHED (deleted at once — read the report first) """ import argparse +import io import json import os import re @@ -47,6 +48,13 @@ KEY = "FACEBOOK_API" DEFAULT_EVIDENCE = os.path.join(ROOT, "documentation", "audits", "facebook-page-api-2026-10-08") LOGO = os.path.join(ROOT, "website", "assets", "logo.png") WEEK_S = 7 * 24 * 3600 +# Meta refuses a scheduled_publish_time outside this window. 10 minutes is Meta's own floor; +# the 6-month ceiling is its documented maximum. Both are enforced BEFORE the call, so a bad +# time is a local refusal with a readable reason rather than a Graph error. +SCHED_MIN_S = 10 * 60 +SCHED_MAX_S = 180 * 24 * 3600 +COPY_MD = os.path.join(ROOT, "marketing", "facebook", "COPY.md") +POST_LINK = "https://felhom.eu/" # "Felhom teszt – árvíztűrő tükörfúrógép. Ez a bejegyzés törlődik." — built from escapes, never typed # through a shell (brief §9.8). TEST_TEXT = ("Felhom teszt – árvíztűrő tükörfúrógép. " @@ -274,6 +282,25 @@ def scenario_c(g, page): else json.dumps(c.err, ensure_ascii=False))) +def budapest_to_epoch(text): + """'YYYY-MM-DD HH:MM' in Europe/Budapest -> Unix seconds. Uses the real tz database, so the + summer/winter offset is never guessed.""" + from datetime import datetime + try: + from zoneinfo import ZoneInfo + except ImportError: # pragma: no cover - python < 3.9 + raise ValueError("zoneinfo is unavailable; cannot convert a Budapest wall-clock time safely") + try: + tz = ZoneInfo("Europe/Budapest") + except Exception as e: + # No silent fallback to a hardcoded +01:00/+02:00: guessing the offset is how a post goes + # out an hour early or late across a DST boundary. Refuse and say what is missing. + raise ValueError("no Europe/Budapest in the tz database (%s); install tzdata or run this on " + "the Linux host, where the system zoneinfo is present" % e) + dt = datetime.strptime(text.strip(), "%Y-%m-%d %H:%M").replace(tzinfo=tz) + return int(dt.timestamp()) + + def hexs(s): return (s or "").encode("utf-8").hex() @@ -394,6 +421,126 @@ def scenario_e(g, page, rc): return rc, res +# ------------------------------------------------- schedule-post (R-917, the first post) + +def read_copy_section(path, want): + """Return the fenced block under '### ' in COPY.md, as text. + + The Hungarian never passes through a shell or an argv string (brief §9.6): the post body is + READ FROM THE FILE and sent as UTF-8 bytes. The caller names a section, not a message. + """ + with io.open(path, encoding="utf-8") as fh: + t = fh.read() + marker = "### " + want + i = t.find(marker) + if i < 0: + raise ValueError("no section %r in %s" % (want, path)) + j = t.find("```", i) + if j < 0: + raise ValueError("section %r has no fenced block" % want) + j = t.index("\n", j) + 1 + k = t.find("```", j) + if k < 0: + raise ValueError("section %r has an unclosed fenced block" % want) + return t[j:k].rstrip("\n") + + +def check_when(when, now=None): + """Refuse a scheduled time outside Meta's window. Returns the time; raises ValueError.""" + now = int(time.time()) if now is None else now + delta = int(when) - now + if delta < SCHED_MIN_S: + raise ValueError("scheduled_publish_time is %d s away; Meta's floor is %d s (10 minutes)" + % (delta, SCHED_MIN_S)) + if delta > SCHED_MAX_S: + raise ValueError("scheduled_publish_time is %d s away; Meta's ceiling is %d s (6 months)" + % (delta, SCHED_MAX_S)) + return int(when) + + +def schedule_form(message, when, link=POST_LINK): + """The form for ONE scheduled post. published is ALWAYS 'false' and no argument can change it + (brief §9.7): the operator's review in Planner is the safety net, so this command cannot + publish immediately even by mistake.""" + form = {"message": message, "published": "false", "scheduled_publish_time": str(check_when(when))} + if link: + form["link"] = link + return form + + +def list_scheduled(g, step, pid, ptok): + """The Page's scheduled posts. Two routes are tried and WHICH ONE ANSWERED IS RECORDED — + /scheduled_posts is the documented edge; the is_published=false feed filter is the fallback. + Returns (ids, route) or (None, None) when both are refused, so the caller can say 'unproven' + instead of claiming a removal it did not see.""" + c = g.call(step, "GET", pid + "/scheduled_posts", ptok, query={"fields": "id,is_published", "limit": "50"}) + if c.ok and isinstance(c.body.get("data"), list): + return [d.get("id") for d in c.body["data"]], "scheduled_posts" + c2 = g.call(step + "-feedfallback", "GET", pid + "/feed", ptok, + query={"fields": "id,is_published", "limit": "50", "is_published": "false"}) + if c2.ok and isinstance(c2.body.get("data"), list): + return [d.get("id") for d in c2.body["data"]], "feed?is_published=false" + log(" scheduled-post LIST refused on both routes: %s | %s" + % (json.dumps(c.err, ensure_ascii=False), json.dumps(c2.err, ensure_ascii=False))) + return None, None + + +def scenario_sched_dry(g, page): + """Scenario A: prove the link parameter is safe on a scheduled post, and prove REMOVAL from the + list rather than from an error after DELETE.""" + pid, ptok = page["id"], page["token"] + when = int(time.time()) + WEEK_S + c = g.call("S1-dry-create", "POST", pid + "/feed", ptok, form=schedule_form(TEST_TEXT, when)) + if not c.ok: + log("S link+schedule REFUSED: %s" % json.dumps(c.err, ensure_ascii=False)) + return 6, {"created": False, "error": c.err} + post_id = c.body.get("id") + log("S created %s (scheduled, with link)" % post_id) + rb = g.call("S2-dry-readback", "GET", post_id, ptok, + query={"fields": "message,is_published,scheduled_publish_time,permalink_url"}) + res = check_readback("S", rb, hexs(TEST_TEXT), "message", when) + res["post_id"] = post_id + published = res["is_published"] is True + before, route = list_scheduled(g, "S3-list-before", pid, ptok) + res["list_route"] = route + res["present_before_delete"] = (post_id in before) if before is not None else None + log(" list route=%s present=%s" % (route, res["present_before_delete"])) + res["delete_ok"] = delete(g, "S4-dry-delete", post_id, ptok) + after, _ = list_scheduled(g, "S5-list-after", pid, ptok) + res["absent_after_delete"] = (post_id not in after) if after is not None else None + log(" after delete: absent=%s" % res["absent_after_delete"]) + note(g, "S9-dry-verdict", res) + if published: + log("S !!! the scheduled post read back PUBLISHED — deleted at once") + return 8, res + return 0, res + + +def scenario_sched_post(g, page, message, when): + pid, ptok = page["id"], page["token"] + c = g.call("P1-create", "POST", pid + "/feed", ptok, form=schedule_form(message, when)) + if not c.ok: + log("P REFUSED: %s" % json.dumps(c.err, ensure_ascii=False)) + return 6, {"created": False, "error": c.err} + post_id = c.body.get("id") + log("P scheduled %s" % post_id) + rb = g.call("P2-readback", "GET", post_id, ptok, + query={"fields": "message,is_published,scheduled_publish_time,permalink_url,created_time"}) + res = check_readback("P", rb, hexs(message), "message", when) + res["post_id"] = post_id + res["permalink_url"] = rb.body.get("permalink_url") if rb.ok else None + ids, route = list_scheduled(g, "P3-list", pid, ptok) + res["list_route"] = route + res["in_scheduled_list"] = (post_id in ids) if ids is not None else None + log(" in the scheduled list (%s): %s" % (route, res["in_scheduled_list"])) + if res["is_published"] is True: + log("P !!! read back PUBLISHED — this command must never publish; NOT deleting, tell the operator") + note(g, "P9-verdict", res) + return 8, res + note(g, "P9-verdict", res) + return 0, res + + # ---------------------------------------------------------------- main def main(argv=None): @@ -403,7 +550,13 @@ def main(argv=None): ap.add_argument("--version", default="v26.0", help="Graph API version (default v26.0)") ap.add_argument("--evidence", default=DEFAULT_EVIDENCE) ap.add_argument("--credentials", default=os.path.expanduser("~/.config/credentials")) - ap.add_argument("cmd", choices=("read", "write-test")) + ap.add_argument("cmd", choices=("read", "write-test", "schedule-post")) + ap.add_argument("--section", default="6.1", + help="COPY.md section to post, e.g. 6.1 or 6.2 (schedule-post)") + ap.add_argument("--at", default=None, + help="when to publish, 'YYYY-MM-DD HH:MM' in Europe/Budapest (schedule-post)") + ap.add_argument("--dry", action="store_true", + help="schedule-post: the throwaway link+schedule check, then delete it") a = ap.parse_args(argv) VERBOSE = a.v try: @@ -414,15 +567,16 @@ def main(argv=None): SECRETS.append(token) log("key %s: %d chars, starts %s" % (KEY, len(token), token[:3])) # write-test re-derives A and B into its own sub-directory, so the read run's files stay as they were - g = Graph(a.version, a.evidence if a.cmd == "read" else os.path.join(a.evidence, "write-test")) + sub = {"read": "", "write-test": "write-test"}.get(a.cmd, "first-post") + g = Graph(a.version, a.evidence if not sub else os.path.join(a.evidence, sub)) try: - return run(a.cmd, g, token) + return run(a.cmd, g, token, a) finally: note(g, "F1-headers", g.headers_seen) # §7 F: recorded once each, on every exit path log("F headers: %s" % json.dumps(g.headers_seen)) -def run(cmd, g, token): +def run(cmd, g, token, a=None): ok_a, _ = scenario_a(g, token) if not ok_a: log("A FAILED") @@ -438,6 +592,26 @@ def run(cmd, g, token): if "CREATE_CONTENT" not in page["tasks"]: log("WRITE GATE: the page's tasks lack CREATE_CONTENT (%s) — no write" % page["tasks"]) return 5 + if cmd == "schedule-post": + if a.dry: + rc, _ = scenario_sched_dry(g, page) + log("schedule-post --dry: done (rc=%d)" % rc) + return rc + if not a.at: + log("schedule-post needs --at 'YYYY-MM-DD HH:MM' (Europe/Budapest)") + return 2 + try: + when = budapest_to_epoch(a.at) + check_when(when) + message = read_copy_section(COPY_MD, a.section) + except (ValueError, OSError) as e: + log("REFUSED: %s" % e) + return 2 + log("P section %s: %d characters, publishing at %s (epoch %d)" + % (a.section, len(message), a.at, when)) + rc, _ = scenario_sched_post(g, page, message, when) + log("schedule-post: done (rc=%d)" % rc) + return rc rc, _ = scenario_d(g, page, 0) if rc != 0: return rc diff --git a/scripts/facebook/test_fb_probe.py b/scripts/facebook/test_fb_probe.py index 1b47464a..df6ddc11 100644 --- a/scripts/facebook/test_fb_probe.py +++ b/scripts/facebook/test_fb_probe.py @@ -81,5 +81,111 @@ class GoneError(unittest.TestCase): self.assertFalse(fb_probe.gone_error(None)) +# ---------------------------------------------------------------- schedule-post (R-917) + +class ScheduleForm(unittest.TestCase): + """The guard that matters: this command can only SCHEDULE. The operator's review in Planner is + the safety net, so an immediate post must be unreachable — not merely undefaulted.""" + + def test_published_is_always_false(self): + f = fb_probe.schedule_form("x", fb_probe.time.time() + 3600) + self.assertEqual(f["published"], "false") + + def test_no_argument_can_publish_immediately(self): + # every keyword the function accepts, tried: none of them flips `published` + import inspect + names = [p for p in inspect.signature(fb_probe.schedule_form).parameters if p not in ("message", "when")] + for name in names: + f = fb_probe.schedule_form("x", fb_probe.time.time() + 3600, **{name: "true"}) + self.assertEqual(f["published"], "false", "%s changed published" % name) + + def test_link_is_included_by_default(self): + f = fb_probe.schedule_form("x", fb_probe.time.time() + 3600) + self.assertEqual(f["link"], "https://felhom.eu/") + + def test_link_can_be_dropped(self): + f = fb_probe.schedule_form("x", fb_probe.time.time() + 3600, link=None) + self.assertNotIn("link", f) + + +class ScheduleWindow(unittest.TestCase): + NOW = 1_760_000_000 + + def test_too_soon_is_refused(self): + with self.assertRaises(ValueError): + fb_probe.check_when(self.NOW + 9 * 60, now=self.NOW) + + def test_in_the_past_is_refused(self): + with self.assertRaises(ValueError): + fb_probe.check_when(self.NOW - 60, now=self.NOW) + + def test_too_far_is_refused(self): + with self.assertRaises(ValueError): + fb_probe.check_when(self.NOW + fb_probe.SCHED_MAX_S + 60, now=self.NOW) + + def test_ten_minutes_exactly_is_allowed(self): + self.assertEqual(fb_probe.check_when(self.NOW + 600, now=self.NOW), self.NOW + 600) + + def test_a_normal_time_is_allowed(self): + self.assertEqual(fb_probe.check_when(self.NOW + 86400, now=self.NOW), self.NOW + 86400) + + def test_schedule_form_refuses_a_bad_time_too(self): + # the guard is not only on check_when: the form builder must not assemble a bad post + with self.assertRaises(ValueError): + fb_probe.schedule_form("x", 0) + + +class CopySection(unittest.TestCase): + """The post body is READ FROM COPY.md, never passed through a shell (brief 9.6).""" + + def test_reads_the_fenced_block(self): + txt = fb_probe.read_copy_section(fb_probe.COPY_MD, "6.1 Rövid változat") + self.assertIn("felhom.eu/kapcsolat", txt) + self.assertFalse(txt.startswith("```")) + self.assertFalse(txt.endswith(chr(10))) + + def test_sections_differ(self): + a = fb_probe.read_copy_section(fb_probe.COPY_MD, "6.1 Rövid változat") + b = fb_probe.read_copy_section(fb_probe.COPY_MD, "6.2 Közepes változat") + self.assertNotEqual(a, b) + self.assertGreater(len(b), len(a)) + + def test_missing_section_raises(self): + with self.assertRaises(ValueError): + fb_probe.read_copy_section(fb_probe.COPY_MD, "9.9 nincs ilyen") + + +class ScheduleRedaction(unittest.TestCase): + def test_a_token_in_a_scheduled_form_is_redacted(self): + f = fb_probe.schedule_form("see " + FAKE, fb_probe.time.time() + 3600) + self.assertNotIn(FAKE, fb_probe.redact(f, secrets=[FAKE])["message"]) + + +def _has_budapest_tz(): + try: + from zoneinfo import ZoneInfo + ZoneInfo("Europe/Budapest") + return True + except Exception: + return False + + +@unittest.skipUnless(_has_budapest_tz(), + "no Europe/Budapest in this interpreter's tz database (Windows ships none; " + "the command runs on the Linux host, which has the system zoneinfo)") +class BudapestTime(unittest.TestCase): + def test_summer_and_winter_offsets_differ(self): + # CEST in July (+02:00), CET in January (+01:00) — the tz database, not a guessed offset + jul = fb_probe.budapest_to_epoch("2026-07-01 19:00") + jan = fb_probe.budapest_to_epoch("2026-01-01 19:00") + import datetime + self.assertEqual(datetime.datetime.utcfromtimestamp(jul).hour, 17) + self.assertEqual(datetime.datetime.utcfromtimestamp(jan).hour, 18) + + def test_bad_format_raises(self): + with self.assertRaises(ValueError): + fb_probe.budapest_to_epoch("tomorrow evening") + + if __name__ == "__main__": unittest.main()