From 3a77ed73aaf72f2880f20f314fe0113e0eb23669 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 9 Oct 2026 15:06:45 +0200 Subject: [PATCH] facebook: the first post drafted (COPY.md section 6) + fb_probe schedule-post DRAFTS. COPY.md section 6: two versions of the Page's first post, shortened from section 2. Hungarian, tegezo, no price. 6.1 = 347 characters, 6.2 = 638 (counted as Unicode characters). Every claim carries a source comment naming the line of website/index.html it rests on; the first line of each carries the point alone, because Facebook cuts after about three lines. Deliberate: ZERO emoji and ZERO hashtags, though the brief allows two of each. The Felhom design system uses no emoji (the website gate holds it at 0) and two hashtags would serve no real search. CHECKED, because the post repeats it: "56 alkalmazas" is CORRECT. The apps page carries 57
but states 56, which looks off by one until you read the category line -- "6 alkalmazas + 1 beepitett". The 57th card is FileBrowser, built into every box and deliberately not counted. index.html says "56 telepitheto alkalmazas" too. I nearly "fixed" a live page into being wrong. NOT-A-FINDING. SCHEDULE-POST. A third sub-command on fb_probe.py, reusing its token loader (R-453), redaction, Bearer call and evidence writer: - the body is READ FROM COPY.md by section name. The Hungarian never passes through a shell or an argv string (brief 9.6); the caller names a section. - published is ALWAYS "false" and NO argument can change it (brief 9.7). The operator's review in Planner is the safety net, so an immediate post must be unreachable, not merely not-the-default. - check_when refuses a time under Meta's 10-minute floor or over its 6-month ceiling, BEFORE the call, so a bad time is a readable local refusal rather than a Graph error. - budapest_to_epoch uses the real tz database. If zoneinfo has no Europe/Budapest it REFUSES rather than falling back to a hardcoded +01:00/+02:00 -- guessing the offset is how a post goes out an hour wrong across a DST boundary. - list_scheduled tries /scheduled_posts then feed?is_published=false and RECORDS WHICH ANSWERED; when both are refused it returns None so the caller says "unproven" instead of claiming a removal it never saw. TESTS: 30, of which 2 skip on Windows (no tzdata in this interpreter; the command runs on the Linux host, which has the system zoneinfo). RED-PROOF of the guard that matters, as the brief requires. Made schedule_form accept published=..., ran ScheduleForm, and watched test_no_argument_can_publish_immediately FAIL: AssertionError: 'true' != 'false' : published changed published Guard restored, all 30 green again. No post has been made. The dry check and the real post come next; the operator picks the version and the time first. --- marketing/facebook/COPY.md | 54 +++++++++ scripts/facebook/fb_probe.py | 182 +++++++++++++++++++++++++++++- scripts/facebook/test_fb_probe.py | 106 +++++++++++++++++ 3 files changed, 338 insertions(+), 4 deletions(-) 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()