IGDM shadow: add Business Login connect flow
This commit is contained in:
parent
79fe6b7cf6
commit
98150f5631
9 changed files with 4423 additions and 17 deletions
64
README.md
64
README.md
|
|
@ -6,7 +6,8 @@ Includes:
|
|||
- Meta Graph API exporter (full DM history)
|
||||
- Instagram “Download your information” importer
|
||||
- DM analysis pipeline (bot-vs-human, conversions, objections, rescue logic, product eras)
|
||||
- Helpers for device login + page subscription + sending replies
|
||||
- IGDM Shadow Mode webhook server (draft-only)
|
||||
- Legacy helpers for device login + token derivation (history export)
|
||||
|
||||
## Where this runs
|
||||
|
||||
|
|
@ -33,7 +34,9 @@ Required for export:
|
|||
|
||||
Note: in this LXC, `docker run` / `podman run` can fail due to AppArmor confinement. Use the **direct Python** commands below unless you change the LXC config to be AppArmor-unconfined.
|
||||
|
||||
## Obtain tokens (pct 250)
|
||||
## Obtain tokens (pct 250) — history export only
|
||||
|
||||
Note: these steps are for the **history exporter**. For **real-time IG DMs** into `https://emo-social.infrafabric.io/igdm`, use **Instagram Business Login** (see “Webhooks” below).
|
||||
|
||||
`meta_device_login` requires `META_CLIENT_TOKEN` (from Meta app dashboard → Settings → Advanced → Client token).
|
||||
Alternatively, set `META_CLIENT_ACCESS_TOKEN` as `APP_ID|CLIENT_TOKEN` to override `META_APP_ID` for device-login only.
|
||||
|
|
@ -154,6 +157,7 @@ Generate the deeper “no raw quotes” report directly from an Instagram export
|
|||
It also encodes a hard rule for the bot:
|
||||
|
||||
- Always reply in the **user’s input language** (English / Spanish / French / Catalan), with a short clarification if the user’s message is too short to detect.
|
||||
- If language is too short to detect: reuse the last language seen in that thread; if still unknown, **default to Spanish** (no language menus).
|
||||
|
||||
Regenerate from a local Instagram export folder:
|
||||
|
||||
|
|
@ -168,25 +172,59 @@ Multi-language ready-made answers for the Top question topics (aligned to the Vo
|
|||
|
||||
## Webhooks (new messages → auto-reply)
|
||||
|
||||
Meta webhooks are two steps:
|
||||
To receive real Instagram DMs (inbound + outbound echo) you need:
|
||||
|
||||
1) App subscription (`/{app_id}/subscriptions`) — sets callback URL + verify token + fields.
|
||||
2) Page subscription (`/{page_id}/subscribed_apps`) — attaches the Page/IG inbox to the app.
|
||||
1) Webhooks product configured in the Meta app (callback URL + verify token + instagram fields).
|
||||
2) An **Instagram Business Login** token with `instagram_business_manage_messages`.
|
||||
3) The IG account subscribed to the app (`POST /me/subscribed_apps` on `graph.instagram.com`).
|
||||
|
||||
The production webhook endpoint exists at:
|
||||
|
||||
- `https://emo-social.infrafabric.io/meta/webhook`
|
||||
|
||||
### Subscribe the Page to the app (required for message delivery)
|
||||
## Shadow mode (draft-only) — operational
|
||||
|
||||
This requires a Page access token that includes `pages_manage_metadata`.
|
||||
This repo includes a **draft-only** webhook server that:
|
||||
- receives Meta webhook events (including `is_echo` outgoing messages)
|
||||
- writes a draft reply (Top 20 templates + language mirroring)
|
||||
- stores the draft and later links it to the **actual** outgoing reply for side-by-side comparison
|
||||
- never sends a message (unless you explicitly add a sending step later)
|
||||
|
||||
Device login with that scope:
|
||||
Language policy (pragmatic):
|
||||
- Default to Spanish for unclear first messages; switch automatically once the user writes clearly in French/English/Catalan.
|
||||
|
||||
- `cd /root/ai-workspace/emo-social-insta-dm-agent && python3 -m sergio_instagram_messaging.meta_device_login start --scope pages_show_list,pages_read_engagement,instagram_manage_messages,pages_manage_metadata`
|
||||
- After authorizing in the browser, poll and write tokens:
|
||||
- `cd /root/ai-workspace/emo-social-insta-dm-agent && python3 -m sergio_instagram_messaging.meta_device_login poll --write-page-token --target-ig-user-id 17841466913731557`
|
||||
Run locally (requires `META_VERIFY_TOKEN` + `META_APP_SECRET` in env; and for `/meta/ig/connect` also `META_APP_ID` + `IGDM_IG_REDIRECT_URI`):
|
||||
|
||||
Then subscribe the Page:
|
||||
- `python3 -m sergio_instagram_messaging.igdm_shadow_server --host 127.0.0.1 --port 5051 --db /root/tmp/igdm/igdm.sqlite --reply-library reply_library/top20_ready_answers.json`
|
||||
|
||||
- `cd /root/ai-workspace/emo-social-insta-dm-agent && python3 -m sergio_instagram_messaging.meta_subscribe_page subscribe --fields messages`
|
||||
Production (`pct 220`):
|
||||
- systemd: `igdm-shadow.service` (listens on `127.0.0.1:5051`)
|
||||
- nginx routes:
|
||||
- `/meta/webhook` → `igdm-shadow` (no auth, required by Meta)
|
||||
- `/meta/ig/connect` → `igdm-shadow` (OAuth gated; starts Instagram Business Login)
|
||||
- `/meta/ig/callback` → `igdm-shadow` (public; used by Instagram OAuth redirect)
|
||||
- `/igdm` and `/api/igdm/*` → `igdm-shadow` (OAuth gated via `oauth2-proxy`)
|
||||
|
||||
### Connect Instagram Business Login (required for message delivery)
|
||||
|
||||
If **no real DMs** are showing up in `https://emo-social.infrafabric.io/igdm`, the most common missing step is that Instagram Business Login was never completed (so Meta never starts sending webhook events).
|
||||
|
||||
1) In Meta app dashboard → Instagram Business Login → Settings, add redirect URL:
|
||||
- `https://emo-social.infrafabric.io/meta/ig/callback`
|
||||
2) Visit the dashboard:
|
||||
- `https://emo-social.infrafabric.io/igdm`
|
||||
3) Click **Connect / Reconnect Instagram (Business Login)** and complete the Instagram consent screen.
|
||||
4) Send a DM to `@socialmediatorr` (e.g. “book”, “livre”) and confirm it appears in the table.
|
||||
|
||||
The server stores the IG long-lived access token (mode `600`) at:
|
||||
- `pct 220`: `/opt/if-emotion/data/igdm/ig_token.json`
|
||||
|
||||
### Legacy (not recommended): device login + Page subscription
|
||||
|
||||
Older Meta flows use Facebook device login + Page-scoped subscriptions. They frequently fail with “invalid scopes” and are not required if Instagram Business Login is working.
|
||||
|
||||
If you still need them for debugging, see the scripts:
|
||||
|
||||
- `python3 -m sergio_instagram_messaging.meta_device_login`
|
||||
- `python3 -m sergio_instagram_messaging.meta_page_token_from_user_token`
|
||||
- `python3 -m sergio_instagram_messaging.meta_subscribe_page`
|
||||
|
|
|
|||
920
docs/external_review/external_llm_review_packet_public.md
Normal file
920
docs/external_review/external_llm_review_packet_public.md
Normal file
|
|
@ -0,0 +1,920 @@
|
|||
# External Review Packet (Public) — emo-social + Instagram DM Draft Assistant
|
||||
|
||||
**Last updated:** 2025-12-25
|
||||
**Audience:** external reviewers (human or LLM)
|
||||
**Public-safety note:** this packet contains **no private DM transcripts**, **no personal identities**, and **no secrets/tokens**. It uses aggregated counts and “made-for-review” draft examples only.
|
||||
|
||||
---
|
||||
|
||||
## How to use this packet (for reviewers)
|
||||
|
||||
1) Read Sections 1–6 (context + boundaries + neutral stats + current draft artifacts).
|
||||
2) Answer Section 7 (the review request).
|
||||
3) If anything is unclear, ask questions in Section 7.7 instead of guessing.
|
||||
|
||||
---
|
||||
|
||||
## 1) What this is (in one paragraph)
|
||||
|
||||
This is a clinically supervised research stress-test for `emo-social.infrafabric.io`, prepared as part of an Anthropic Fellowship application. It is **not** a commercial product. In parallel, we are building a separate component: an Instagram DM “draft assistant” for `@socialmediatorr`. The DM assistant is **not** therapy, and it must never handle crisis content; it is meant to reduce delays on repetitive inbox questions while keeping trust intact.
|
||||
|
||||
---
|
||||
|
||||
## 2) What we are building (two parts)
|
||||
|
||||
### 2.1 emo-social.infrafabric.io (research system)
|
||||
|
||||
Purpose: help users think more clearly about emotions using a defined “Sergio” style: direct, concrete, anti-vague, and language-mirroring (reply in the user’s language).
|
||||
|
||||
What it is **not**:
|
||||
- Not a medical device.
|
||||
- Not a replacement for professional care.
|
||||
- Not a crisis service.
|
||||
|
||||
### 2.2 Instagram DM draft assistant for `@socialmediatorr`
|
||||
|
||||
Purpose: produce **draft replies** for incoming Instagram DMs, using the same language as the user, aligned with Sergio’s concise DM style. The “draft” can be reviewed by a human before sending.
|
||||
|
||||
What it is **not**:
|
||||
- Not an auto-messaging spam tool.
|
||||
- Not ManyChat “broadcast marketing”.
|
||||
- Not therapy-by-DM.
|
||||
- Not allowed to respond to self-harm or crisis situations.
|
||||
|
||||
---
|
||||
|
||||
## 3) Hard boundaries (non-negotiable rules)
|
||||
|
||||
These are the constraints reviewers should assume.
|
||||
|
||||
### 3.1 Safety boundaries
|
||||
- If a message looks like crisis, self-harm, or imminent danger: **stop** and hand off to a human.
|
||||
- No deep personal/clinical guidance inside Instagram DMs.
|
||||
- No diagnosis, no medical claims.
|
||||
|
||||
### 3.2 Privacy boundaries
|
||||
- Do not store or export names, handles, phone numbers, emails, or full message transcripts as “training data”.
|
||||
- Use **counts and grouped themes** whenever possible.
|
||||
- Any “training pairs” used internally must be redacted and access-controlled (not in public repos).
|
||||
|
||||
### 3.3 Behaviour boundaries
|
||||
- Reply in the **user’s input language** (English / Spanish / French / Catalan). Do not mix languages unless the user mixes first.
|
||||
- Keep DM replies short. Ask one simple question when it helps the conversation continue.
|
||||
- If the user message is too short to detect language (one word / emoji): reuse the last known language in that thread; if still unclear, ask a 1‑line language question.
|
||||
|
||||
---
|
||||
|
||||
## 4) The evaluation method (how we avoid harming real clients)
|
||||
|
||||
We run the Instagram DM agent in **draft-only mode** first:
|
||||
- A new DM arrives.
|
||||
- The current system reply (if any) stays unchanged.
|
||||
- The new system writes a draft reply, but does **not** send it.
|
||||
- We compare “what was sent” vs “what we would have sent” in a table.
|
||||
|
||||
This allows safety review and quality scoring before any automation is allowed to send messages.
|
||||
|
||||
---
|
||||
|
||||
## 5) Neutral inbox stats (aggregated, no identities)
|
||||
|
||||
**Inbox:** `@socialmediatorr`
|
||||
**Time zone used here:** CET
|
||||
**Observed window:** 2024-10-20 → 2025-12-22 (429 days)
|
||||
|
||||
### 5.1 Basic counts (whole window)
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| Total messages | 54,069 |
|
||||
| Messages sent by owner | 43,607 |
|
||||
| Messages sent by others | 10,462 |
|
||||
| Messages that look like a question/request | 2,715 |
|
||||
| System “new follower” messages | 8,081 |
|
||||
|
||||
Important context: this export is **not evenly distributed**. **2025‑12** dominates the dataset (87.5% of messages). Earlier months are less reliable for “trend” claims.
|
||||
|
||||
### 5.2 Top 20 things people ask (by meaning, not exact wording)
|
||||
|
||||
| Rank | Topic (plain English) | Count | Share of questions/requests |
|
||||
|---:|---|---:|---:|
|
||||
| 1 | Just one word: book | 1,857 | 68.4% |
|
||||
| 2 | What is this? | 203 | 7.5% |
|
||||
| 3 | Can you send the video? | 189 | 7.0% |
|
||||
| 4 | Other question | 118 | 4.3% |
|
||||
| 5 | Can you help me? | 74 | 2.7% |
|
||||
| 6 | Can you send the link? | 70 | 2.6% |
|
||||
| 7 | What does it cost? | 53 | 2.0% |
|
||||
| 8 | Is this therapy? | 44 | 1.6% |
|
||||
| 9 | Where do I get the book? | 36 | 1.3% |
|
||||
| 10 | I can’t find it / it didn’t arrive | 26 | 1.0% |
|
||||
| 11 | How do I book a call? | 11 | 0.4% |
|
||||
| 12 | How do I start? | 10 | 0.4% |
|
||||
| 13 | Can we talk on WhatsApp? | 7 | 0.3% |
|
||||
| 14 | How does it work? | 5 | 0.2% |
|
||||
| 15 | What are the steps? | 4 | 0.1% |
|
||||
| 16 | Is this real? | 4 | 0.1% |
|
||||
| 17 | Is it free? | 2 | 0.1% |
|
||||
| 18 | Can I get a refund? | 1 | 0.0% |
|
||||
| 19 | How long does it take? | 1 | 0.0% |
|
||||
|
||||
Note: #20 is not present in the data-driven list above; we added one practical “coverage” item in the draft reply library (“Where are you based?”) because it is common in real inboxes even when it didn’t rank here.
|
||||
|
||||
### 5.3 When messages arrive (CET)
|
||||
|
||||
| Time block (CET) | Messages from people |
|
||||
|---|---:|
|
||||
| 00:00–05:59 | 2,113 |
|
||||
| 06:00–11:59 | 1,274 |
|
||||
| 12:00–17:59 | 2,333 |
|
||||
| 18:00–23:59 | 4,742 |
|
||||
|
||||
### 5.4 Day-of-week pattern
|
||||
|
||||
| Day of week | Messages from people | Questions/requests |
|
||||
|---|---:|---:|
|
||||
| Monday | 1,600 | 131 |
|
||||
| Tuesday | 1,939 | 194 |
|
||||
| Wednesday | 1,282 | 159 |
|
||||
| Thursday | 2,261 | 1,268 |
|
||||
| Friday | 1,705 | 803 |
|
||||
| Saturday | 833 | 87 |
|
||||
| Sunday | 842 | 73 |
|
||||
|
||||
### 5.5 Language mix (important caveat)
|
||||
|
||||
A large share of inbound messages are **too short to detect** (one word, emoji, “book”). That is not “unknown language”; it is “insufficient text to classify”.
|
||||
|
||||
Approx language estimate (inbound, “enough text” only):
|
||||
- English ~54%
|
||||
- Spanish ~42%
|
||||
- French ~4%
|
||||
- Catalan ~0.3%
|
||||
|
||||
---
|
||||
|
||||
## 6) Draft artifacts to review (safe to share)
|
||||
|
||||
These are included so you can critique the approach, not so you “agree with it”.
|
||||
|
||||
### 6.1 VoiceDNA file (last 6 months, manual replies only)
|
||||
|
||||
This is a rules + stats fingerprint generated from the last 6 months of **manual (non-template) DM replies**. It includes:
|
||||
- language-mirroring rules
|
||||
- reply length distribution
|
||||
- response timing distribution (within a response window)
|
||||
- *no* verbatim client DMs
|
||||
|
||||
Full JSON is in Appendix A.
|
||||
|
||||
### 6.2 Top 20 ready-made answers (multi-language, placeholders)
|
||||
|
||||
These are draft answers for the Top 20 topics. They include placeholders like `{BOOK_LINK}` and `{PRICE}` (no real links inside this packet).
|
||||
Full list is in Appendix B.
|
||||
|
||||
### 6.3 if.emotion “Sergio” system prompt (reference)
|
||||
|
||||
We include this as a reference point for the style constraints used in `emo-social.infrafabric.io`.
|
||||
Full text is in Appendix C.
|
||||
|
||||
---
|
||||
|
||||
## 7) The review request (please answer this)
|
||||
|
||||
### 7.1 Required tone
|
||||
|
||||
Be objective. Do **not** flatter. If you think something is wrong or risky, say so plainly.
|
||||
“Nice idea” without specific criticism is not helpful.
|
||||
|
||||
### 7.2 Your first line
|
||||
|
||||
State:
|
||||
- the model/provider you are (or likely are)
|
||||
- your estimated knowledge cutoff
|
||||
- whether you could access this URL content directly (yes/no)
|
||||
|
||||
### 7.3 What looks strong (short list)
|
||||
|
||||
List what is actually strong *and why* (no generic praise).
|
||||
|
||||
### 7.4 Gaps / risks (ranked, with severity)
|
||||
|
||||
Identify gaps we may have missed across:
|
||||
- safety
|
||||
- privacy
|
||||
- quality of replies (tone, clarity, wrong-language risk)
|
||||
- operational failure modes (how this breaks in real inboxes)
|
||||
- “bad incentives” (what it might push people toward unintentionally)
|
||||
|
||||
Rank them by severity (highest risk first).
|
||||
|
||||
### 7.5 Patches to consider (concrete)
|
||||
|
||||
For each patch:
|
||||
- describe the change in 1–3 sentences
|
||||
- explain the reason in 1 sentence
|
||||
- if it’s a prompt change, show the exact lines you would add/remove
|
||||
- if it’s a process change, describe the exact step you would add
|
||||
|
||||
### 7.6 A safe test plan (step-by-step)
|
||||
|
||||
Propose a small, safe test plan that:
|
||||
- starts in draft-only mode
|
||||
- uses a simple scoring method (clear / correct language / correct next step / no overreach)
|
||||
- escalates risky content to a human
|
||||
- makes it hard to “fake success”
|
||||
|
||||
### 7.7 Transferability to other verticals
|
||||
|
||||
Could this approach transfer to other areas (support, sales, onboarding, triage)?
|
||||
- What transfers unchanged?
|
||||
- What must be rebuilt from scratch?
|
||||
- Where does it become unsafe?
|
||||
|
||||
### 7.8 Clarifying questions (exactly 5)
|
||||
|
||||
Ask 5 questions that would most improve your review if answered.
|
||||
|
||||
---
|
||||
|
||||
## 8) Optional “role prompts” (if you want to run 3 reviews)
|
||||
|
||||
If you are running multiple independent reviews, assign one of these roles to each reviewer:
|
||||
|
||||
### Role A — Safety reviewer
|
||||
Focus on: harm, escalation rules, “what must never happen”, and abuse cases.
|
||||
|
||||
### Role B — Product reviewer
|
||||
Focus on: usefulness, clarity, real inbox behaviour, and what will fail in practice.
|
||||
|
||||
### Role C — Engineering reviewer
|
||||
Focus on: how to test it, how to measure it, and how to prevent silent failure.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — VoiceDNA JSON (safe; no private DM quotes)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "voice_dna/v1",
|
||||
"created_at_utc": "2025-12-24T12:08:24+00:00",
|
||||
"subject": {
|
||||
"account": "@socialmediatorr",
|
||||
"owner_name": "Sergio de Vocht",
|
||||
"scope": "Instagram DMs"
|
||||
},
|
||||
"source": {
|
||||
"type": "instagram_export",
|
||||
"window": {
|
||||
"months": 6,
|
||||
"start_utc": "2025-06-24T12:08:20+00:00",
|
||||
"end_utc": "2025-12-24T12:08:20+00:00",
|
||||
"response_window_hours": 72.0
|
||||
},
|
||||
"classification": {
|
||||
"manual_reply_definition": "owner text message within response_window_hours of the most recent inbound message, excluding repeated templates",
|
||||
"scripted_template_definition": "owner canonicalized text sent >= 50 times across full export",
|
||||
"system_messages_excluded": [
|
||||
"you messaged <user> because they followed your account"
|
||||
]
|
||||
},
|
||||
"scan": {
|
||||
"export_root_hint": "socialmediatorr-ig-export-raw-20251224",
|
||||
"scanned_conversations": 10100,
|
||||
"scanned_message_files": 10061,
|
||||
"candidate_responses_in_window": 18934,
|
||||
"manual_responses_in_window": 825,
|
||||
"scripted_template_count": 24
|
||||
}
|
||||
},
|
||||
"policies": {
|
||||
"language": {
|
||||
"mode": "mirror_user_input_language",
|
||||
"supported_languages": [
|
||||
"English",
|
||||
"Spanish",
|
||||
"French",
|
||||
"Catalan"
|
||||
],
|
||||
"rules": [
|
||||
"Reply in the same language as the user's most recent message that contains enough text to classify.",
|
||||
"Do not translate the user's message unless they explicitly ask for a translation.",
|
||||
"Do not mix languages inside a single reply unless the user mixes languages first.",
|
||||
"If the user's message is too short to classify, reuse the last confidently detected language in the same thread.",
|
||||
"If there is still no signal, ask a 1-line clarification asking which language they prefer (keep it short)."
|
||||
]
|
||||
}
|
||||
},
|
||||
"language_observed": {
|
||||
"inbound_last_window_counts": {
|
||||
"English": 2828,
|
||||
"Too short to tell": 5024,
|
||||
"Spanish": 2176,
|
||||
"French": 191,
|
||||
"Catalan": 16
|
||||
},
|
||||
"manual_reply_counts": {
|
||||
"Spanish": 541,
|
||||
"Too short to tell": 255,
|
||||
"English": 17,
|
||||
"French": 1,
|
||||
"Catalan": 11
|
||||
}
|
||||
},
|
||||
"style": {
|
||||
"manual_replies": {
|
||||
"overall": {
|
||||
"count": 825,
|
||||
"length": {
|
||||
"chars": {
|
||||
"min": 2,
|
||||
"p10": 13,
|
||||
"median": 54,
|
||||
"p90": 199,
|
||||
"max": 928,
|
||||
"mean": 87.47878787878788
|
||||
},
|
||||
"words": {
|
||||
"min": 1,
|
||||
"p10": 2,
|
||||
"median": 11,
|
||||
"p90": 34,
|
||||
"max": 169,
|
||||
"mean": 15.436363636363636
|
||||
}
|
||||
},
|
||||
"rates": {
|
||||
"emoji_messages_pct": 16.363636363636363,
|
||||
"question_messages_pct": 32.484848484848484,
|
||||
"ends_with_question_pct": 27.151515151515156,
|
||||
"exclamation_messages_pct": 18.303030303030305,
|
||||
"linebreak_messages_pct": 9.212121212121211,
|
||||
"url_messages_pct": 4.121212121212121,
|
||||
"handle_messages_pct": 0.0,
|
||||
"number_messages_pct": 9.454545454545455,
|
||||
"starts_with_greeting_pct": 4.96969696969697,
|
||||
"contains_thanks_pct": 4.484848484848484,
|
||||
"contains_cta_terms_pct": 4.848484848484849
|
||||
},
|
||||
"top_emojis": [
|
||||
{
|
||||
"emoji": "🙌",
|
||||
"count": 41
|
||||
},
|
||||
{
|
||||
"emoji": "🫶",
|
||||
"count": 23
|
||||
},
|
||||
{
|
||||
"emoji": "👋",
|
||||
"count": 19
|
||||
},
|
||||
{
|
||||
"emoji": "🎁",
|
||||
"count": 17
|
||||
},
|
||||
{
|
||||
"emoji": "💪",
|
||||
"count": 14
|
||||
},
|
||||
{
|
||||
"emoji": "👇",
|
||||
"count": 12
|
||||
},
|
||||
{
|
||||
"emoji": "😜",
|
||||
"count": 7
|
||||
},
|
||||
{
|
||||
"emoji": "😉",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"emoji": "🤣",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"emoji": "🙏",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"emoji": "⬆",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"emoji": "👀",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"emoji": "¦",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "😅",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "🤝",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "☺",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "🫡",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "😁",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "👌",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "🚀",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"by_language": {
|
||||
"Spanish": {
|
||||
"count": 541,
|
||||
"length": {
|
||||
"chars": {
|
||||
"min": 5,
|
||||
"p10": 35,
|
||||
"median": 99,
|
||||
"p90": 211,
|
||||
"max": 928,
|
||||
"mean": 116.06469500924214
|
||||
},
|
||||
"words": {
|
||||
"min": 1,
|
||||
"p10": 6,
|
||||
"median": 18,
|
||||
"p90": 38,
|
||||
"max": 169,
|
||||
"mean": 20.478743068391868
|
||||
}
|
||||
},
|
||||
"rates": {
|
||||
"emoji_messages_pct": 20.70240295748614,
|
||||
"question_messages_pct": 39.55637707948244,
|
||||
"ends_with_question_pct": 32.34750462107209,
|
||||
"exclamation_messages_pct": 16.266173752310536,
|
||||
"linebreak_messages_pct": 13.67837338262477,
|
||||
"url_messages_pct": 2.2181146025878005,
|
||||
"handle_messages_pct": 0.0,
|
||||
"number_messages_pct": 9.242144177449168,
|
||||
"starts_with_greeting_pct": 7.578558225508318,
|
||||
"contains_thanks_pct": 6.839186691312385,
|
||||
"contains_cta_terms_pct": 7.024029574861368
|
||||
},
|
||||
"top_emojis": [
|
||||
{
|
||||
"emoji": "🙌",
|
||||
"count": 24
|
||||
},
|
||||
{
|
||||
"emoji": "🫶",
|
||||
"count": 23
|
||||
},
|
||||
{
|
||||
"emoji": "👋",
|
||||
"count": 19
|
||||
},
|
||||
{
|
||||
"emoji": "🎁",
|
||||
"count": 17
|
||||
},
|
||||
{
|
||||
"emoji": "👇",
|
||||
"count": 12
|
||||
},
|
||||
{
|
||||
"emoji": "💪",
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"emoji": "😜",
|
||||
"count": 7
|
||||
},
|
||||
{
|
||||
"emoji": "😉",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"emoji": "🤣",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"emoji": "🙏",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"emoji": "⬆",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"emoji": "👀",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"emoji": "😅",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "🤝",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "☺",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "😁",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "👌",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "🚀",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "⏱",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"emoji": "🫂",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"Too short to tell": {
|
||||
"count": 255,
|
||||
"length": {
|
||||
"chars": {
|
||||
"min": 2,
|
||||
"p10": 10,
|
||||
"median": 30,
|
||||
"p90": 53,
|
||||
"max": 151,
|
||||
"mean": 32.01176470588236
|
||||
},
|
||||
"words": {
|
||||
"min": 1,
|
||||
"p10": 1,
|
||||
"median": 4,
|
||||
"p90": 11,
|
||||
"max": 29,
|
||||
"mean": 5.749019607843137
|
||||
}
|
||||
},
|
||||
"rates": {
|
||||
"emoji_messages_pct": 7.8431372549019605,
|
||||
"question_messages_pct": 19.607843137254903,
|
||||
"ends_with_question_pct": 17.647058823529413,
|
||||
"exclamation_messages_pct": 23.52941176470588,
|
||||
"linebreak_messages_pct": 0.39215686274509803,
|
||||
"url_messages_pct": 4.705882352941177,
|
||||
"handle_messages_pct": 0.0,
|
||||
"number_messages_pct": 7.0588235294117645,
|
||||
"starts_with_greeting_pct": 0.0,
|
||||
"contains_thanks_pct": 0.0,
|
||||
"contains_cta_terms_pct": 0.0
|
||||
},
|
||||
"top_emojis": [
|
||||
{
|
||||
"emoji": "🙌",
|
||||
"count": 15
|
||||
},
|
||||
{
|
||||
"emoji": "💪",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"emoji": "🫡",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"English": {
|
||||
"count": 17,
|
||||
"length": {
|
||||
"chars": {
|
||||
"min": 4,
|
||||
"p10": 11,
|
||||
"median": 23,
|
||||
"p90": 38,
|
||||
"max": 40,
|
||||
"mean": 23.176470588235293
|
||||
},
|
||||
"words": {
|
||||
"min": 2,
|
||||
"p10": 3,
|
||||
"median": 4,
|
||||
"p90": 6,
|
||||
"max": 8,
|
||||
"mean": 4.588235294117647
|
||||
}
|
||||
},
|
||||
"rates": {
|
||||
"emoji_messages_pct": 11.76470588235294,
|
||||
"question_messages_pct": 23.52941176470588,
|
||||
"ends_with_question_pct": 23.52941176470588,
|
||||
"exclamation_messages_pct": 17.647058823529413,
|
||||
"linebreak_messages_pct": 0.0,
|
||||
"url_messages_pct": 0.0,
|
||||
"handle_messages_pct": 0.0,
|
||||
"number_messages_pct": 0.0,
|
||||
"starts_with_greeting_pct": 0.0,
|
||||
"contains_thanks_pct": 0.0,
|
||||
"contains_cta_terms_pct": 11.76470588235294
|
||||
},
|
||||
"top_emojis": [
|
||||
{
|
||||
"emoji": "🙌",
|
||||
"count": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"French": {
|
||||
"count": 1,
|
||||
"length": {
|
||||
"chars": {
|
||||
"min": 29,
|
||||
"p10": 29,
|
||||
"median": 29,
|
||||
"p90": 29,
|
||||
"max": 29,
|
||||
"mean": 29.0
|
||||
},
|
||||
"words": {
|
||||
"min": 5,
|
||||
"p10": 5,
|
||||
"median": 5,
|
||||
"p90": 5,
|
||||
"max": 5,
|
||||
"mean": 5.0
|
||||
}
|
||||
},
|
||||
"rates": {
|
||||
"emoji_messages_pct": 100.0,
|
||||
"question_messages_pct": 0.0,
|
||||
"ends_with_question_pct": 0.0,
|
||||
"exclamation_messages_pct": 0.0,
|
||||
"linebreak_messages_pct": 0.0,
|
||||
"url_messages_pct": 0.0,
|
||||
"handle_messages_pct": 0.0,
|
||||
"number_messages_pct": 0.0,
|
||||
"starts_with_greeting_pct": 0.0,
|
||||
"contains_thanks_pct": 0.0,
|
||||
"contains_cta_terms_pct": 0.0
|
||||
},
|
||||
"top_emojis": [
|
||||
{
|
||||
"emoji": "¦",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"Catalan": {
|
||||
"count": 11,
|
||||
"length": {
|
||||
"chars": {
|
||||
"min": 26,
|
||||
"p10": 48,
|
||||
"median": 52,
|
||||
"p90": 99,
|
||||
"max": 205,
|
||||
"mean": 72.0909090909091
|
||||
},
|
||||
"words": {
|
||||
"min": 5,
|
||||
"p10": 5,
|
||||
"median": 8,
|
||||
"p90": 10,
|
||||
"max": 34,
|
||||
"mean": 9.727272727272727
|
||||
}
|
||||
},
|
||||
"rates": {
|
||||
"emoji_messages_pct": 0.0,
|
||||
"question_messages_pct": 0.0,
|
||||
"ends_with_question_pct": 0.0,
|
||||
"exclamation_messages_pct": 0.0,
|
||||
"linebreak_messages_pct": 9.090909090909092,
|
||||
"url_messages_pct": 90.9090909090909,
|
||||
"handle_messages_pct": 0.0,
|
||||
"number_messages_pct": 90.9090909090909,
|
||||
"starts_with_greeting_pct": 0.0,
|
||||
"contains_thanks_pct": 0.0,
|
||||
"contains_cta_terms_pct": 0.0
|
||||
},
|
||||
"top_emojis": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — Top 20 ready-made answers (multi-language, placeholders only)
|
||||
|
||||
# Top 20 Ready-Made Answers (Multi‑Language)
|
||||
|
||||
**VoiceDNA:** `voice_dna/voiceDNA_socialmediatorr_insta_dm.json`
|
||||
|
||||
Rules:
|
||||
- Reply in the **same language as the user’s message** (English / Spanish / French / Catalan).
|
||||
- Replace `{PLACEHOLDERS}` before sending.
|
||||
- Keep it short; end with a question when it helps the conversation continue.
|
||||
|
||||
---
|
||||
|
||||
## 1) Just one word: book
|
||||
|
||||
- English: `Got it. Do you want the book link or the video first?`
|
||||
- Spanish: `Perfecto 🙌\n¿Quieres el enlace del ebook o prefieres que te mande el vídeo primero?`
|
||||
- French: `OK. Tu veux le lien du livre ou la vidéo d’abord ?`
|
||||
- Catalan: `Perfecte. Vols l’enllaç del llibre o el vídeo primer?`
|
||||
|
||||
## 2) What is this?
|
||||
|
||||
- English: `It’s a simple, practical resource to help you move forward. What are you looking for right now?`
|
||||
- Spanish: `Es un recurso práctico para avanzar.\n¿Qué estás buscando ahora mismo?`
|
||||
- French: `C’est un outil simple et pratique pour avancer. Tu cherches quoi en ce moment ?`
|
||||
- Catalan: `És un recurs simple i pràctic per avançar. Què busques ara mateix?`
|
||||
|
||||
## 3) Can you send the video?
|
||||
|
||||
- English: `Sure — here it is: {VIDEO_LINK}. Want a quick summary first?`
|
||||
- Spanish: `Claro 🙌 Aquí va el vídeo: {VIDEO_LINK}\n¿Lo quieres con resumen en 3 líneas o lo ves y me dices qué te resonó?`
|
||||
- French: `Bien sûr. Voilà la vidéo : {VIDEO_LINK}\nTu veux un résumé rapide ou tu la regardes et tu me dis ce qui t’a parlé ?`
|
||||
- Catalan: `Sí. Aquí tens el vídeo: {VIDEO_LINK}\nVols un resum ràpid o el mires i em dius què t’ha ressonat?`
|
||||
|
||||
## 4) Other question
|
||||
|
||||
- English: `Tell me in one sentence what you need, and I’ll point you to the right next step.`
|
||||
- Spanish: `Dime en 1 frase qué necesitas y te digo el siguiente paso 🙌`
|
||||
- French: `Dis‑moi en 1 phrase ce que tu veux, et je te dis la prochaine étape.`
|
||||
- Catalan: `Digue’m en 1 frase què necessites i et dic el següent pas.`
|
||||
|
||||
## 5) Can you help me?
|
||||
|
||||
- English: `Yes. What are you struggling with most right now?`
|
||||
- Spanish: `Sí.\n¿Qué es lo que más te está costando ahora mismo?`
|
||||
- French: `Oui. Qu’est‑ce qui est le plus difficile pour toi en ce moment ?`
|
||||
- Catalan: `Sí. Què és el que et costa més ara mateix?`
|
||||
|
||||
## 6) Can you send the link?
|
||||
|
||||
- English: `Sure. Is this for the book or to book a call?`
|
||||
- Spanish: `Claro.\n¿El enlace es para el ebook o para reservar una llamada?`
|
||||
- French: `Bien sûr. C’est pour le livre ou pour réserver un appel ?`
|
||||
- Catalan: `Clar. És per al llibre o per reservar una trucada?`
|
||||
|
||||
## 7) What does it cost?
|
||||
|
||||
- English: `It’s {PRICE}. Want the link?`
|
||||
- Spanish: `Son {PRICE}.\n¿Te paso el enlace?`
|
||||
- French: `C’est {PRICE}. Tu veux le lien ?`
|
||||
- Catalan: `Són {PRICE}. Vols l’enllaç?`
|
||||
|
||||
## 8) Is this therapy?
|
||||
|
||||
- English: `No — this isn’t therapy in DMs. I can share resources and options. What are you looking for?`
|
||||
- Spanish: `No, esto no es terapia por DM.\nPuedo ayudarte con recursos y opciones.\n¿Qué estás buscando?`
|
||||
- French: `Non, ce n’est pas une thérapie en DM. Je peux te partager des ressources et des options. Tu cherches quoi ?`
|
||||
- Catalan: `No, això no és teràpia per DM. Puc compartir recursos i opcions. Què busques?`
|
||||
|
||||
## 9) Where do I get the book?
|
||||
|
||||
- English: `Here you go: {BOOK_LINK}. Did it open for you?`
|
||||
- Spanish: `Aquí tienes el enlace: {BOOK_LINK}\n¿Te llega bien?`
|
||||
- French: `Tiens : {BOOK_LINK}. Ça s’ouvre bien de ton côté ?`
|
||||
- Catalan: `Aquí ho tens: {BOOK_LINK}. Se t’obre bé?`
|
||||
|
||||
## 10) I can’t find it / it didn’t arrive
|
||||
|
||||
- English: `No worries — let’s fix it. What email did you use to buy (or send a screenshot of the receipt)?`
|
||||
- Spanish: `Vale, lo solucionamos.\n¿Con qué email lo compraste? (o mándame una captura del recibo)`
|
||||
- French: `OK, on règle ça. Tu as acheté avec quel e‑mail ? (ou envoie une capture du reçu)`
|
||||
- Catalan: `Ho arreglem. Amb quin e‑mail ho vas comprar? (o envia una captura del rebut)`
|
||||
|
||||
## 11) How do I book a call?
|
||||
|
||||
- English: `Book a call here: {CALENDLY_LINK}. What times work for you?`
|
||||
- Spanish: `Reserva aquí: {CALENDLY_LINK}\n¿Qué horarios te vienen bien?`
|
||||
- French: `Réserve ici : {CALENDLY_LINK}. Quels créneaux te vont ?`
|
||||
- Catalan: `Reserva aquí: {CALENDLY_LINK}. Quins horaris et van bé?`
|
||||
|
||||
## 12) How do I start?
|
||||
|
||||
- English: `To start, tell me your goal in one sentence and I’ll tell you the first step.`
|
||||
- Spanish: `Para empezar: dime tu objetivo en 1 frase y te digo el primer paso 🙌`
|
||||
- French: `Pour commencer, dis‑moi ton objectif en 1 phrase et je te donne le premier pas.`
|
||||
- Catalan: `Per començar, digue’m el teu objectiu en 1 frase i et dic el primer pas.`
|
||||
|
||||
## 13) Can we talk on WhatsApp?
|
||||
|
||||
- English: `We can. If you prefer WhatsApp: {WHATSAPP_LINK}. Want to continue there?`
|
||||
- Spanish: `Podemos.\nSi te va mejor WhatsApp: {WHATSAPP_LINK}\n¿Te va bien que lo hablemos por allí?`
|
||||
- French: `Oui. Si tu préfères WhatsApp : {WHATSAPP_LINK}. Ça te va de continuer là‑bas ?`
|
||||
- Catalan: `Sí. Si prefereixes WhatsApp: {WHATSAPP_LINK}. Et va bé seguir per allà?`
|
||||
|
||||
## 14) How does it work?
|
||||
|
||||
- English: `Simple: you get the resource, apply it, and if you want we can go deeper. What do you want to improve?`
|
||||
- Spanish: `Es simple:\n1) te paso el recurso\n2) lo aplicas\n3) si quieres, lo afinamos juntos\n¿Qué quieres mejorar?`
|
||||
- French: `C’est simple : je te partage le contenu, tu l’appliques, et si tu veux on va plus loin. Tu veux améliorer quoi ?`
|
||||
- Catalan: `És simple: et passo el recurs, l’apliques, i si vols ho aprofundim. Què vols millorar?`
|
||||
|
||||
## 15) What are the steps?
|
||||
|
||||
- English: `1) Tell me what you want 2) I send the right link 3) You start. What’s your goal?`
|
||||
- Spanish: `1) me dices qué buscas\n2) te mando el enlace correcto\n3) empiezas\n¿Cuál es tu objetivo ahora mismo?`
|
||||
- French: `1) Tu me dis ce que tu veux 2) je t’envoie le bon lien 3) tu commences. Ton objectif, c’est quoi ?`
|
||||
- Catalan: `1) Em dius què busques 2) t’envio l’enllaç correcte 3) comences. Quin és el teu objectiu?`
|
||||
|
||||
## 16) Is this real?
|
||||
|
||||
- English: `Yes. If you want, I’ll send the official link. What are you looking to get out of it?`
|
||||
- Spanish: `Sí, es real.\nSi quieres, te mando el enlace oficial.\n¿Qué estás buscando conseguir?`
|
||||
- French: `Oui, c’est réel. Si tu veux je t’envoie le lien officiel. Tu veux obtenir quoi exactement ?`
|
||||
- Catalan: `Sí, és real. Si vols, t’envio l’enllaç oficial. Què vols aconseguir exactament?`
|
||||
|
||||
## 17) Is it free?
|
||||
|
||||
- English: `The full thing isn’t free, but I can share a free starting point. Want it?`
|
||||
- Spanish: `No es gratis, pero sí tengo un punto de inicio gratuito.\n¿Quieres que te lo pase?`
|
||||
- French: `Ce n’est pas gratuit, mais j’ai un point de départ gratuit. Tu veux que je te l’envoie ?`
|
||||
- Catalan: `No és gratis, però tinc un punt d’inici gratuït. Vols que te’l passi?`
|
||||
|
||||
## 18) Can I get a refund?
|
||||
|
||||
- English: `Sure — happy to help. Send me the purchase email + date and I’ll sort it.`
|
||||
- Spanish: `Claro, te ayudo.\nPásame el email de compra + la fecha y lo reviso.`
|
||||
- French: `Bien sûr. Envoie‑moi l’e‑mail d’achat + la date et je m’en occupe.`
|
||||
- Catalan: `És clar. Envia’m l’e‑mail de compra + la data i ho gestiono.`
|
||||
|
||||
## 19) How long does it take?
|
||||
|
||||
- English: `It depends, but usually {TIMEFRAME}. What are you trying to change?`
|
||||
- Spanish: `Depende, pero normalmente {TIMEFRAME}.\n¿Qué quieres cambiar tú?`
|
||||
- French: `Ça dépend, mais en général {TIMEFRAME}. Tu veux changer quoi, toi ?`
|
||||
- Catalan: `Depèn, però normalment {TIMEFRAME}. Què vols canviar tu?`
|
||||
|
||||
## 20) Where are you based?
|
||||
|
||||
Note: added as a practical top‑20 coverage item.
|
||||
|
||||
- English: `I’m based in {BASE_LOCATION} (CET) and I work online. Where are you based?`
|
||||
- Spanish: `Estoy en {BASE_LOCATION} (CET) y trabajo online.\n¿Tú dónde estás?`
|
||||
- French: `Je suis basé à {BASE_LOCATION} (CET) et je travaille en ligne. Et toi, tu es où ?`
|
||||
- Catalan: `Sóc a {BASE_LOCATION} (CET) i treballo online. I tu, on ets?`
|
||||
|
||||
---
|
||||
|
||||
## Appendix C — if.emotion “Sergio” system prompt (reference)
|
||||
|
||||
```text
|
||||
You are Sergio, a Spanish-speaking psychologist who specializes in debunking abstract spiritual language and operationalizing emotional concepts into concrete, testable frameworks.
|
||||
|
||||
CORE PERSONALITY:
|
||||
- Direct and brash communication style - challenge unfalsifiable claims
|
||||
- Systems-first thinker (context/culture factors, not individual blame)
|
||||
- Bilingual Spanish/English capability, but you must match the user's language; only code-switch if explicitly asked
|
||||
- Convert abstract emotional language into concrete operational definitions
|
||||
|
||||
VOICE GUIDELINES (MANDATORY):
|
||||
- NEVER use bullet points or numbered lists - always narrative flow
|
||||
- Always respond in the same language as the user's last message
|
||||
- Variable sentence length pattern: short punchy + longer flowing explanation + short again
|
||||
- Validate emotional reality FIRST, then challenge interpretation
|
||||
- End with concrete operationalization: ask "What specific behavior in the next 30 minutes?" (in the user's language)
|
||||
|
||||
RHETORICAL TOOLS:
|
||||
- Aspiradora metaphor: When someone drowns in complexity, simplify to binary. "Una aspiradora no necesita 50 tipos de suciedad etiquetados. It needs one question: Is there dirt? Yes or no?"
|
||||
- Reframing: "The problem isn't X. The problem is Y."
|
||||
|
||||
SPANISH USAGE:
|
||||
- Only when the user is writing in Spanish:
|
||||
- Use Spanish for emotional validation: "Mira, eso no está mal"
|
||||
- Use colloquial markers: tío, vale, pues, mira
|
||||
- NEVER use formal Spanish: no obstante, asimismo, consecuentemente
|
||||
|
||||
ANTI-PATTERNS (NEVER DO):
|
||||
- Never pathologize neurodivergence - frame as context mismatch, not deficit
|
||||
- Never use "Furthermore", "In conclusion", "One could argue"
|
||||
- Never give prescriptions without mechanism explanations
|
||||
```
|
||||
|
||||
1097
docs/external_review/external_llm_review_packet_public_v2.md
Normal file
1097
docs/external_review/external_llm_review_packet_public_v2.md
Normal file
File diff suppressed because it is too large
Load diff
249
docs/governance/IF_GOV_IGDM_SPEC.md
Normal file
249
docs/governance/IF_GOV_IGDM_SPEC.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# IF.GOV + IF.TTT Spec — Instagram DM Draft Assistant (`@socialmediatorr`)
|
||||
|
||||
**Status:** proposal (POC)
|
||||
**Constraint:** no paid external LLM APIs → “debates” are simulated using deterministic seats (rules) and optional local models only.
|
||||
|
||||
This spec describes how to implement the Instagram DM assistant as an **auditable governance pipeline**:
|
||||
- **IF.GOV.TRIAGE** decides risk + route (normal vs human vs urgent).
|
||||
- **IF.GOV.PANEL** simulates a multi‑seat review of the proposed draft reply (no external APIs required).
|
||||
- **IF.TTT** records a chain‑of‑custody (hashes + decisions + evidence bundle) so results are provable later.
|
||||
|
||||
---
|
||||
|
||||
## 0) System boundaries (what we will and won’t do)
|
||||
|
||||
### In scope
|
||||
- Ingest Meta webhook events for Instagram DMs.
|
||||
- Produce **draft replies** (default) using templates + simple intent routing.
|
||||
- Escalate a tiny fraction of DMs to a human (Sergio) quickly, with a direct “open thread” link.
|
||||
- Produce IF.TTT‑style trace records and evidence bundles for audit/replay.
|
||||
- Run “panel debates” **without external APIs** (rule seats + optional local model seats).
|
||||
|
||||
### Out of scope (for the POC)
|
||||
- Automatic sending of replies to real clients (keep `draft-only`).
|
||||
- Therapy-by-DM, crisis intervention, diagnosis, or medical claims.
|
||||
- Storing/exporting full DM transcripts in a public repo.
|
||||
|
||||
---
|
||||
|
||||
## 1) High-level architecture
|
||||
|
||||
### Components
|
||||
- **Webhook receiver** (already exists in production on `emo-social.infrafabric.io`): verifies Meta signature and normalizes events.
|
||||
- **Event store**: append-only storage of DM events + derived decisions (local, private).
|
||||
- **Triage engine** (`IF.GOV.TRIAGE`): risk + language + intent + confidence.
|
||||
- **Draft engine**: chooses a reply template (Top 20) or a safe fallback.
|
||||
- **Panel engine** (`IF.GOV.PANEL`): simulated debate across “seats” → approve/patch/escalate.
|
||||
- **Trace recorder** (`IF.TTT`): emits signed decision records + evidence bundles.
|
||||
- **Reviewer UI**: queue view for Drafts + Escalations + “open IG thread” action.
|
||||
|
||||
### Data flow (valid Mermaid)
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W[Meta webhook event] --> V[Verify signature]
|
||||
V --> N[Normalize event]
|
||||
N --> ES[Event store append]
|
||||
ES --> T[IF.GOV.TRIAGE]
|
||||
T -->|urgent| E[Escalation record]
|
||||
T -->|normal| D[Draft engine]
|
||||
T -->|needs-human| H[Human-required record]
|
||||
D --> P[IF.GOV.PANEL seats]
|
||||
P --> R[Panel decision]
|
||||
E --> TR[IF.TTT trace + bundle]
|
||||
H --> TR
|
||||
R --> TR
|
||||
TR --> UI[Reviewer UI queue]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) IF.GOV.TRIAGE (no external API)
|
||||
|
||||
### Inputs
|
||||
- `sender_id` (from webhook)
|
||||
- `mid` (message id)
|
||||
- `timestamp_ms`
|
||||
- `text` (if present; empty allowed)
|
||||
- minimal thread context (last N messages for this sender_id, if available)
|
||||
|
||||
### Outputs (contract)
|
||||
```json
|
||||
{
|
||||
"triage_version": "if.gov.triage/igdm/v1",
|
||||
"trace_id": "uuid",
|
||||
"ts_utc": "2025-12-25T12:00:00Z",
|
||||
"time_cet": "2025-12-25T13:00:00+01:00",
|
||||
"sender_id": "123",
|
||||
"mid": "m_abc",
|
||||
"language": { "code": "es", "confidence": 0.86, "source": "text_or_thread" },
|
||||
"intent": { "label": "book|link|video|price|help|other", "confidence": 0.90 },
|
||||
"risk": {
|
||||
"tier": "normal|needs-human|urgent",
|
||||
"score": 0.05,
|
||||
"reasons": ["..."],
|
||||
"panel_size": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Triage rules (POC defaults)
|
||||
- **Language detection**
|
||||
- If message has enough text: detect language from message text.
|
||||
- Else: reuse last confident thread language.
|
||||
- Else: set `confidence < 0.5` and prefer a 1‑line language question.
|
||||
- **Intent detection**
|
||||
- Keyword routing for: `book`, `link`, `video`, `price/cost`, `call`, `therapy`, etc.
|
||||
- If unknown: intent=`other` with low confidence.
|
||||
- **Risk tier**
|
||||
- `urgent` if self-harm/suicide signals OR violence/abuse indicators.
|
||||
- `needs-human` if: therapeutic disclosure, legal threats, harassment, complex personal crisis, repeated angry loop.
|
||||
- `normal` otherwise.
|
||||
|
||||
### “Panel size” without external APIs
|
||||
Panel size is computed deterministically from `risk.score` (same pattern as the existing `guard_engine.py`):
|
||||
- normal: 5 seats
|
||||
- needs-human: 10 seats (more checks, but still local)
|
||||
- urgent: 20 seats (but action is always escalate, not debate content)
|
||||
|
||||
---
|
||||
|
||||
## 3) Draft engine (no external API)
|
||||
|
||||
### Principles
|
||||
- Use **templates first**, not a generative model.
|
||||
- Always mirror the user’s language (or ask a 1‑line language question if uncertain).
|
||||
- Keep replies short; ask one clear next question when helpful.
|
||||
- Never invite deep disclosure in DMs; route to “resources / call / book link”.
|
||||
|
||||
### Draft outputs
|
||||
```json
|
||||
{
|
||||
"draft_version": "igdm.draft/v1",
|
||||
"trace_id": "uuid",
|
||||
"template_id": "top20:book:v1:es",
|
||||
"text": "…",
|
||||
"placeholders": ["BOOK_LINK"],
|
||||
"notes": ["language=es", "intent=book"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) IF.GOV.PANEL (simulated debates)
|
||||
|
||||
### What “debate” means here
|
||||
Because we are not calling external LLMs, the “panel” is a set of **deterministic seat evaluators**.
|
||||
Each seat emits:
|
||||
- a vote (`approve` | `request_changes` | `veto`)
|
||||
- reasons (human readable)
|
||||
- patch suggestions (structured)
|
||||
|
||||
### Seat roster (minimum viable, 5 seats)
|
||||
1) **Safety seat**: blocks crisis mishandling; ensures no harmful advice.
|
||||
2) **Boundary seat**: prevents therapy-by-DM; rewrites “help” flows into routing.
|
||||
3) **Language seat**: enforces same-language output; no mixing; handles low confidence.
|
||||
4) **Privacy seat**: avoids unnecessary PII; flags risky asks (phone/email) unless explicitly required.
|
||||
5) **Next-step seat**: checks the reply has a clear next step (link or one question).
|
||||
|
||||
Optional seats (when panel size grows)
|
||||
- **Tone/VoiceDNA seat**: checks length + emoji pattern + directness vs DM voice rules.
|
||||
- **Spam/abuse seat**: detects harassment loops and routes to block/report guidance.
|
||||
- **Contrarian seat**: tries to misread the message and see if the draft fails.
|
||||
|
||||
### Seat output format
|
||||
```json
|
||||
{
|
||||
"seat": "language",
|
||||
"vote": "approve|request_changes|veto",
|
||||
"severity": 0.0,
|
||||
"reasons": ["..."],
|
||||
"patches": [
|
||||
{ "op": "replace_text", "path": "draft.text", "value": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Panel aggregation (deterministic)
|
||||
- If any seat returns `veto` → panel decision becomes `escalate_human` (or `urgent_escalate`).
|
||||
- Else if any seat returns `request_changes` → apply patches (in order), re-run seats once.
|
||||
- Else → approve.
|
||||
|
||||
### Panel decision record
|
||||
```json
|
||||
{
|
||||
"panel_version": "if.gov.panel/igdm/v1",
|
||||
"trace_id": "uuid",
|
||||
"panel_size": 5,
|
||||
"seats": [ { "...": "..." } ],
|
||||
"decision": "approve_draft|revise_draft|escalate_human|urgent_escalate",
|
||||
"final_draft_text_sha256": "…",
|
||||
"reason_summary": "short"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Escalation UX (how Sergio actually sees it)
|
||||
|
||||
### Escalation record
|
||||
```json
|
||||
{
|
||||
"escalation_version": "igdm.escalation/v1",
|
||||
"trace_id": "uuid",
|
||||
"tier": "urgent|needs-human",
|
||||
"reason_codes": ["self_harm_signal"],
|
||||
"sender_id": "123",
|
||||
"mid": "m_abc",
|
||||
"time_cet": "2025-12-25T21:13:00+01:00",
|
||||
"open_links": {
|
||||
"instagram_thread": "https://www.instagram.com/direct/t/<conversation_id>/",
|
||||
"fb_inbox": "https://business.facebook.com/latest/inbox/all/?asset_id=<page_id>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Notification strategy (POC)
|
||||
No paid services required:
|
||||
- Show escalations in a **logged-in dashboard** on `emo-social.infrafabric.io`.
|
||||
- Optional: email later (requires SMTP relay configured); not required for the POC.
|
||||
|
||||
---
|
||||
|
||||
## 6) IF.TTT trace + evidence bundles (provable without leaking)
|
||||
|
||||
### Two-bundle approach (recommended)
|
||||
- **Private bundle** (internal): includes raw message text, stored locally with strict permissions.
|
||||
- **Public bundle** (shareable): contains hashes + redacted previews only.
|
||||
|
||||
### Bundle contents (public)
|
||||
```
|
||||
bundle/
|
||||
manifest.json
|
||||
event.json
|
||||
triage.json
|
||||
draft.json
|
||||
panel.json
|
||||
escalation.json (only if escalated)
|
||||
sha256sums.txt
|
||||
signature_ed25519.txt
|
||||
```
|
||||
|
||||
### Minimum “public” fields
|
||||
- `message_text_sha256` (not raw)
|
||||
- `draft_text_sha256` (not raw)
|
||||
- triage + panel decision + reason codes
|
||||
- timestamps (UTC + CET)
|
||||
|
||||
This is enough to prove: “given these bytes (committed), these deterministic governance steps happened, and this decision was produced”.
|
||||
|
||||
---
|
||||
|
||||
## 7) Rollout plan (safe)
|
||||
|
||||
1) **Triage-only** + escalation queue (no drafts yet).
|
||||
2) **Draft-only** templates for Top 20 intents (no sending).
|
||||
3) Add simulated **IF.GOV.PANEL** seats and store panel decisions.
|
||||
4) Emit IF.TTT bundles for each event (public + private).
|
||||
5) Add comparison table: `draft` vs `actual sent` (manual) to measure quality.
|
||||
6) Only after measured success: consider limited auto-send for *low-risk* intents, with a kill switch.
|
||||
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"language_mode": "mirror_user_input_language",
|
||||
"notes": [
|
||||
"Pick the reply language to match the user's most recent message with enough text to classify (English / Spanish / French / Catalan).",
|
||||
"If the user message is too short to detect, reuse the last detected language in the thread; if still unknown, ask which language they prefer.",
|
||||
"If the user message is too short to detect, reuse the last detected language in the thread; if still unknown, default to Spanish.",
|
||||
"Replace {PLACEHOLDERS} before sending."
|
||||
],
|
||||
"placeholders": {
|
||||
|
|
@ -242,4 +242,3 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
1139
sergio_instagram_messaging/igdm_shadow.py
Normal file
1139
sergio_instagram_messaging/igdm_shadow.py
Normal file
File diff suppressed because it is too large
Load diff
745
sergio_instagram_messaging/igdm_shadow_server.py
Normal file
745
sergio_instagram_messaging/igdm_shadow_server.py
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .igdm_shadow import (
|
||||
IgdmStore,
|
||||
ReplyLibrary,
|
||||
meta_signature_is_valid,
|
||||
process_inbound_message,
|
||||
)
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return str(os.getenv(name, default) or "").strip()
|
||||
|
||||
|
||||
def _read_json_body(handler: BaseHTTPRequestHandler) -> tuple[bytes, dict[str, Any] | None]:
|
||||
try:
|
||||
length = int(handler.headers.get("Content-Length") or "0")
|
||||
except Exception:
|
||||
length = 0
|
||||
raw = handler.rfile.read(max(0, length)) if length else b""
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8") or "{}")
|
||||
if not isinstance(payload, dict):
|
||||
return raw, None
|
||||
return raw, payload
|
||||
except Exception:
|
||||
return raw, None
|
||||
|
||||
|
||||
def _json(handler: BaseHTTPRequestHandler, status: int, obj: Any) -> None:
|
||||
data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
handler.send_header("Content-Length", str(len(data)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(data)
|
||||
|
||||
|
||||
def _text(handler: BaseHTTPRequestHandler, status: int, body: str, *, content_type: str = "text/plain; charset=utf-8") -> None:
|
||||
data = body.encode("utf-8")
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", content_type)
|
||||
handler.send_header("Content-Length", str(len(data)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(data)
|
||||
|
||||
|
||||
def _html(handler: BaseHTTPRequestHandler, body: str) -> None:
|
||||
_text(handler, 200, body, content_type="text/html; charset=utf-8")
|
||||
|
||||
|
||||
def _redirect(handler: BaseHTTPRequestHandler, url: str, *, status: int = 302) -> None:
|
||||
handler.send_response(status)
|
||||
handler.send_header("Location", url)
|
||||
handler.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
handler.send_header("Content-Length", "0")
|
||||
handler.end_headers()
|
||||
|
||||
|
||||
def _safe_write_json(path: Path, obj: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
finally:
|
||||
try:
|
||||
os.close(fd)
|
||||
except Exception:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = path.read_text("utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
except Exception:
|
||||
return None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
|
||||
def _http_json(method: str, url: str, *, data: bytes | None = None, headers: dict[str, str] | None = None) -> tuple[int, dict[str, Any] | None, str]:
|
||||
req = urllib.request.Request(url, data=data, method=method.upper())
|
||||
req.add_header("Accept", "application/json")
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310
|
||||
status = int(getattr(resp, "status", 200))
|
||||
raw = resp.read().decode("utf-8", errors="ignore")
|
||||
except urllib.error.HTTPError as e: # noqa: PERF203
|
||||
status = int(getattr(e, "code", 500) or 500)
|
||||
raw = (e.read() or b"").decode("utf-8", errors="ignore")
|
||||
except Exception as e:
|
||||
return 0, None, str(e)
|
||||
try:
|
||||
payload = json.loads(raw) if raw else None
|
||||
except Exception:
|
||||
payload = None
|
||||
return status, (payload if isinstance(payload, dict) else None), raw
|
||||
|
||||
|
||||
def _ig_authorize_url(*, app_id: str, redirect_uri: str, scopes: str, state: str) -> str:
|
||||
q = urllib.parse.urlencode(
|
||||
{
|
||||
"client_id": app_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": scopes,
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
return f"https://www.instagram.com/oauth/authorize?{q}"
|
||||
|
||||
|
||||
def _ig_exchange_code_for_short_token(*, app_id: str, app_secret: str, redirect_uri: str, code: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
body = urllib.parse.urlencode(
|
||||
{
|
||||
"client_id": app_id,
|
||||
"client_secret": app_secret,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri,
|
||||
"code": code,
|
||||
}
|
||||
).encode("utf-8")
|
||||
status, payload, raw = _http_json("POST", "https://api.instagram.com/oauth/access_token", data=body)
|
||||
if status != 200 or payload is None:
|
||||
return None, raw
|
||||
return payload, None
|
||||
|
||||
|
||||
def _ig_exchange_short_for_long(*, app_secret: str, short_token: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
q = urllib.parse.urlencode(
|
||||
{
|
||||
"grant_type": "ig_exchange_token",
|
||||
"client_secret": app_secret,
|
||||
"access_token": short_token,
|
||||
}
|
||||
)
|
||||
status, payload, raw = _http_json("GET", f"https://graph.instagram.com/access_token?{q}")
|
||||
if status != 200 or payload is None:
|
||||
return None, raw
|
||||
return payload, None
|
||||
|
||||
|
||||
def _ig_subscribe_webhooks(*, api_version: str, access_token: str, subscribed_fields: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
url = f"https://graph.instagram.com/{api_version}/me/subscribed_apps"
|
||||
body = urllib.parse.urlencode({"subscribed_fields": subscribed_fields, "access_token": access_token}).encode("utf-8")
|
||||
status, payload, raw = _http_json("POST", url, data=body)
|
||||
if status != 200 or payload is None:
|
||||
return None, raw
|
||||
return payload, None
|
||||
|
||||
|
||||
def _basic_dashboard_html() -> str:
|
||||
return """<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>IGDM Shadow Mode</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 24px; color: #0f172a; }
|
||||
h1 { margin: 0 0 8px 0; font-size: 20px; }
|
||||
.note { color: #475569; margin: 0 0 16px 0; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border-bottom: 1px solid #e2e8f0; padding: 10px 8px; vertical-align: top; font-size: 13px; }
|
||||
th { text-align: left; color: #334155; font-weight: 600; }
|
||||
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; border: 1px solid #cbd5e1; color: #334155; }
|
||||
.pill.ok { background: #ecfdf5; border-color: #bbf7d0; }
|
||||
.pill.warn { background: #fffbeb; border-color: #fde68a; }
|
||||
.pill.bad { background: #fef2f2; border-color: #fecaca; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 12px; color: #334155; }
|
||||
.small { color: #64748b; font-size: 12px; }
|
||||
button { cursor: pointer; }
|
||||
pre { white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 10px; border: 1px solid #e2e8f0; }
|
||||
dialog { width: min(980px, 96vw); border: 1px solid #cbd5e1; border-radius: 12px; padding: 0; }
|
||||
dialog::backdrop { background: rgba(15, 23, 42, 0.35); }
|
||||
.dlg-head { display:flex; justify-content: space-between; align-items:center; padding: 12px 14px; border-bottom: 1px solid #e2e8f0; background:#fff; }
|
||||
.dlg-body { padding: 14px; }
|
||||
.grid { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>IGDM Shadow Mode</h1>
|
||||
<p class="note">Drafts are generated and stored, but never sent. Outgoing (echo) messages are captured later for comparison.</p>
|
||||
<p class="note" id="igConn">Instagram connection: checking…</p>
|
||||
<p class="note"><a href="/meta/ig/connect">Connect / Reconnect Instagram (Business Login)</a></p>
|
||||
|
||||
<table id="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time (CET)</th>
|
||||
<th>User</th>
|
||||
<th>Intent</th>
|
||||
<th>Decision</th>
|
||||
<th>Draft</th>
|
||||
<th>Actual (captured)</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<dialog id="dlg">
|
||||
<div class="dlg-head">
|
||||
<div><span class="mono" id="dlgTitle"></span></div>
|
||||
<button id="dlgClose">Close</button>
|
||||
</div>
|
||||
<div class="dlg-body">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="small">Inbound (user)</div>
|
||||
<pre id="inbound"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small">Draft (not sent)</div>
|
||||
<pre id="draft"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:10px"></div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="small">Actual reply (captured)</div>
|
||||
<pre id="actual"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small">Panel decision</div>
|
||||
<pre id="panel"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
const tblBody = document.querySelector("#tbl tbody");
|
||||
const dlg = document.querySelector("#dlg");
|
||||
const dlgTitle = document.querySelector("#dlgTitle");
|
||||
const inbound = document.querySelector("#inbound");
|
||||
const draft = document.querySelector("#draft");
|
||||
const actual = document.querySelector("#actual");
|
||||
const panel = document.querySelector("#panel");
|
||||
document.querySelector("#dlgClose").addEventListener("click", () => dlg.close());
|
||||
|
||||
function pillClass(decision) {
|
||||
if (!decision) return "pill";
|
||||
if (decision.includes("approve")) return "pill ok";
|
||||
if (decision.includes("revise")) return "pill warn";
|
||||
if (decision.includes("escalate")) return "pill bad";
|
||||
return "pill";
|
||||
}
|
||||
|
||||
async function openItem(traceId) {
|
||||
const res = await fetch(`/api/igdm/item/${encodeURIComponent(traceId)}`);
|
||||
const data = await res.json();
|
||||
dlgTitle.textContent = traceId;
|
||||
inbound.textContent = data.inbound_text || "";
|
||||
draft.textContent = data.draft_text || "";
|
||||
actual.textContent = data.actual_text || "";
|
||||
panel.textContent = JSON.stringify(data.panel_json || {}, null, 2);
|
||||
dlg.showModal();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const res = await fetch("/api/igdm/items?limit=100");
|
||||
const data = await res.json();
|
||||
tblBody.innerHTML = "";
|
||||
for (const it of (data.items || [])) {
|
||||
const tr = document.createElement("tr");
|
||||
const tdTime = document.createElement("td");
|
||||
tdTime.textContent = it.time_cet || "";
|
||||
const tdUser = document.createElement("td");
|
||||
tdUser.innerHTML = `<span class="mono">${it.user_id || ""}</span>`;
|
||||
const tdIntent = document.createElement("td");
|
||||
tdIntent.textContent = it.intent_label || "";
|
||||
const tdDecision = document.createElement("td");
|
||||
tdDecision.innerHTML = `<span class="${pillClass(it.panel_decision)}">${it.panel_decision || ""}</span>`;
|
||||
const tdDraft = document.createElement("td");
|
||||
tdDraft.textContent = (it.draft_text || "").slice(0, 140);
|
||||
const tdActual = document.createElement("td");
|
||||
tdActual.textContent = (it.actual_text || "").slice(0, 140);
|
||||
const tdBtn = document.createElement("td");
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = "View";
|
||||
btn.addEventListener("click", () => openItem(it.trace_id));
|
||||
tdBtn.appendChild(btn);
|
||||
|
||||
tr.appendChild(tdTime);
|
||||
tr.appendChild(tdUser);
|
||||
tr.appendChild(tdIntent);
|
||||
tr.appendChild(tdDecision);
|
||||
tr.appendChild(tdDraft);
|
||||
tr.appendChild(tdActual);
|
||||
tr.appendChild(tdBtn);
|
||||
tblBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConn() {
|
||||
const el = document.querySelector("#igConn");
|
||||
try {
|
||||
const res = await fetch("/api/igdm/meta/ig/status");
|
||||
const data = await res.json();
|
||||
if (!data || !data.ok) {
|
||||
el.textContent = "Instagram connection: unavailable";
|
||||
return;
|
||||
}
|
||||
if (!data.connected) {
|
||||
el.textContent = "Instagram connection: not connected (click “Connect” above).";
|
||||
return;
|
||||
}
|
||||
const when = data.subscribed_at ? `, subscribed ${data.subscribed_at}` : "";
|
||||
el.textContent = `Instagram connection: connected${when}`;
|
||||
} catch (e) {
|
||||
el.textContent = "Instagram connection: unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
refreshConn();
|
||||
setInterval(refresh, 8000);
|
||||
setInterval(refreshConn, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class IgdmShadowHandler(BaseHTTPRequestHandler):
|
||||
server_version = "igdm-shadow/0.1"
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path or "/"
|
||||
qs = urllib.parse.parse_qs(parsed.query or "")
|
||||
|
||||
if path == "/health":
|
||||
_json(self, 200, {"ok": True, "service": "igdm-shadow"})
|
||||
return
|
||||
|
||||
if path == "/meta/ig/connect":
|
||||
app_id = self.server.cfg.get("meta_app_id") or ""
|
||||
redirect_uri = self.server.cfg.get("ig_redirect_uri") or ""
|
||||
scopes = self.server.cfg.get("ig_scopes") or ""
|
||||
if not app_id or not redirect_uri:
|
||||
_text(self, 500, "Missing META_APP_ID or IGDM_IG_REDIRECT_URI.")
|
||||
return
|
||||
|
||||
state = secrets.token_urlsafe(32)
|
||||
_safe_write_json(Path(self.server.cfg["ig_state_path"]), {"state": state, "created_at": int(time.time())})
|
||||
_redirect(self, _ig_authorize_url(app_id=app_id, redirect_uri=redirect_uri, scopes=scopes, state=state))
|
||||
return
|
||||
|
||||
if path == "/meta/ig/callback":
|
||||
error = (qs.get("error") or [""])[0]
|
||||
if error:
|
||||
error_desc = (qs.get("error_description") or [""])[0]
|
||||
_html(
|
||||
self,
|
||||
f"<h1>Instagram connect failed</h1><pre>{html.escape(error)}\n{html.escape(error_desc)}</pre><p><a href=\"/igdm\">Back</a></p>",
|
||||
)
|
||||
return
|
||||
|
||||
code = (qs.get("code") or [""])[0]
|
||||
state = (qs.get("state") or [""])[0]
|
||||
if not code or not state:
|
||||
_text(self, 400, "Missing code/state.")
|
||||
return
|
||||
|
||||
state_obj = _read_json_file(Path(self.server.cfg["ig_state_path"])) or {}
|
||||
state_expected = str(state_obj.get("state") or "")
|
||||
created_at = int(state_obj.get("created_at") or 0)
|
||||
if not state_expected or state != state_expected:
|
||||
_text(self, 403, "Invalid state.")
|
||||
return
|
||||
if created_at and int(time.time()) - created_at > 15 * 60:
|
||||
_text(self, 403, "State expired. Please retry connect.")
|
||||
return
|
||||
|
||||
app_id = self.server.cfg.get("meta_app_id") or ""
|
||||
redirect_uri = self.server.cfg.get("ig_redirect_uri") or ""
|
||||
app_secret = self.server.cfg.get("app_secret") or ""
|
||||
if not app_id or not redirect_uri:
|
||||
_text(self, 500, "Missing META_APP_ID or IGDM_IG_REDIRECT_URI.")
|
||||
return
|
||||
|
||||
short_payload, err = _ig_exchange_code_for_short_token(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
code=code,
|
||||
)
|
||||
if err or short_payload is None:
|
||||
_html(self, f"<h1>Instagram connect failed</h1><pre>{html.escape(err or '')}</pre><p><a href=\"/igdm\">Back</a></p>")
|
||||
return
|
||||
|
||||
short_token = str(short_payload.get("access_token") or "")
|
||||
ig_user_id = str(short_payload.get("user_id") or "")
|
||||
if not short_token:
|
||||
_html(self, "<h1>Instagram connect failed</h1><pre>Missing short-lived token.</pre><p><a href=\"/igdm\">Back</a></p>")
|
||||
return
|
||||
|
||||
long_payload, err = _ig_exchange_short_for_long(app_secret=app_secret, short_token=short_token)
|
||||
if err or long_payload is None:
|
||||
_html(self, f"<h1>Instagram connect failed</h1><pre>{html.escape(err or '')}</pre><p><a href=\"/igdm\">Back</a></p>")
|
||||
return
|
||||
|
||||
long_token = str(long_payload.get("access_token") or "")
|
||||
expires_in_raw = long_payload.get("expires_in") or 0
|
||||
expires_in = int(expires_in_raw) if str(expires_in_raw).isdigit() else 0
|
||||
if not long_token:
|
||||
_html(self, "<h1>Instagram connect failed</h1><pre>Missing long-lived token.</pre><p><a href=\"/igdm\">Back</a></p>")
|
||||
return
|
||||
|
||||
subscribed_fields = self.server.cfg.get("ig_subscribed_fields") or "messages"
|
||||
sub_payload, sub_err = _ig_subscribe_webhooks(
|
||||
api_version=self.server.cfg.get("graph_api_version") or "v24.0",
|
||||
access_token=long_token,
|
||||
subscribed_fields=subscribed_fields,
|
||||
)
|
||||
|
||||
token_obj: dict[str, Any] = {
|
||||
"created_at": int(time.time()),
|
||||
"ig_user_id": ig_user_id or None,
|
||||
"access_token": long_token,
|
||||
"expires_in": expires_in or None,
|
||||
"subscribed_fields": subscribed_fields,
|
||||
"subscribed_at": int(time.time()) if sub_payload else None,
|
||||
"subscribe_ok": bool(sub_payload),
|
||||
"subscribe_error": sub_err if sub_err else None,
|
||||
"subscribe_response": sub_payload if sub_payload else None,
|
||||
}
|
||||
_safe_write_json(Path(self.server.cfg["ig_token_path"]), token_obj)
|
||||
|
||||
if sub_payload is None:
|
||||
_html(
|
||||
self,
|
||||
f"<h1>Instagram connected, but webhook subscribe failed</h1><pre>{html.escape(sub_err or '')}</pre><p><a href=\"/igdm\">Back to IGDM</a></p>",
|
||||
)
|
||||
return
|
||||
|
||||
_html(self, "<h1>Instagram connected</h1><p>Webhook subscription enabled.</p><p><a href=\"/igdm\">Back to IGDM</a></p>")
|
||||
return
|
||||
|
||||
if path == "/meta/webhook":
|
||||
expected = self.server.cfg["verify_token"]
|
||||
mode = (qs.get("hub.mode") or [""])[0]
|
||||
token = (qs.get("hub.verify_token") or [""])[0]
|
||||
challenge = (qs.get("hub.challenge") or [""])[0]
|
||||
if mode == "subscribe" and token and challenge:
|
||||
if token == expected:
|
||||
_text(self, 200, challenge)
|
||||
else:
|
||||
_text(self, 403, "Forbidden")
|
||||
return
|
||||
_text(self, 400, "Bad Request")
|
||||
return
|
||||
|
||||
if path in ("/igdm", "/igdm/"):
|
||||
_html(self, _basic_dashboard_html())
|
||||
return
|
||||
|
||||
if path == "/api/igdm/items":
|
||||
try:
|
||||
limit = int((qs.get("limit") or ["100"])[0])
|
||||
except Exception:
|
||||
limit = 100
|
||||
items = self.server.store.list_items(limit=limit)
|
||||
_json(self, 200, {"ok": True, "count": len(items), "items": items})
|
||||
return
|
||||
|
||||
if path == "/api/igdm/meta/ig/status":
|
||||
token_obj = _read_json_file(Path(self.server.cfg["ig_token_path"])) or {}
|
||||
connected = bool(token_obj.get("access_token"))
|
||||
_json(
|
||||
self,
|
||||
200,
|
||||
{
|
||||
"ok": True,
|
||||
"connected": connected,
|
||||
"ig_user_id": token_obj.get("ig_user_id"),
|
||||
"expires_in": token_obj.get("expires_in"),
|
||||
"subscribed_fields": token_obj.get("subscribed_fields"),
|
||||
"subscribed_at": token_obj.get("subscribed_at"),
|
||||
"subscribe_ok": token_obj.get("subscribe_ok"),
|
||||
"subscribe_error": token_obj.get("subscribe_error"),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if path.startswith("/api/igdm/item/"):
|
||||
trace_id = path.split("/api/igdm/item/", 1)[1]
|
||||
item = self.server.store.get_item(trace_id)
|
||||
if not item:
|
||||
_json(self, 404, {"ok": False, "error": "not_found"})
|
||||
return
|
||||
_json(self, 200, {"ok": True, **item})
|
||||
return
|
||||
|
||||
_text(self, 404, "Not Found")
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path or "/"
|
||||
|
||||
if path == "/api/igdm/meta/ig/subscribe":
|
||||
token_obj = _read_json_file(Path(self.server.cfg["ig_token_path"])) or {}
|
||||
token = str(token_obj.get("access_token") or "")
|
||||
if not token:
|
||||
_json(self, 400, {"ok": False, "error": "not_connected"})
|
||||
return
|
||||
subscribed_fields = self.server.cfg.get("ig_subscribed_fields") or "messages"
|
||||
sub_payload, sub_err = _ig_subscribe_webhooks(
|
||||
api_version=self.server.cfg.get("graph_api_version") or "v24.0",
|
||||
access_token=token,
|
||||
subscribed_fields=subscribed_fields,
|
||||
)
|
||||
token_obj.update(
|
||||
{
|
||||
"subscribed_fields": subscribed_fields,
|
||||
"subscribed_at": int(time.time()) if sub_payload else None,
|
||||
"subscribe_ok": bool(sub_payload),
|
||||
"subscribe_error": sub_err if sub_err else None,
|
||||
"subscribe_response": sub_payload if sub_payload else None,
|
||||
}
|
||||
)
|
||||
_safe_write_json(Path(self.server.cfg["ig_token_path"]), token_obj)
|
||||
if sub_payload is None:
|
||||
_json(self, 502, {"ok": False, "error": "subscribe_failed", "details": sub_err or ""})
|
||||
return
|
||||
_json(self, 200, {"ok": True, "result": sub_payload})
|
||||
return
|
||||
|
||||
if path != "/meta/webhook":
|
||||
_text(self, 404, "Not Found")
|
||||
return
|
||||
|
||||
raw, payload = _read_json_body(self)
|
||||
if payload is None:
|
||||
_text(self, 400, "Bad Request")
|
||||
return
|
||||
|
||||
signature = self.headers.get("X-Hub-Signature-256", "") or ""
|
||||
if not meta_signature_is_valid(raw, signature, self.server.cfg["app_secret"]):
|
||||
_text(self, 403, "Forbidden")
|
||||
return
|
||||
|
||||
entries = payload.get("entry") or []
|
||||
events_total = 0
|
||||
inbound_total = 0
|
||||
outbound_total = 0
|
||||
drafts_total = 0
|
||||
linked_total = 0
|
||||
|
||||
for entry in entries if isinstance(entries, list) else []:
|
||||
messaging_events = entry.get("messaging") or []
|
||||
if not isinstance(messaging_events, list):
|
||||
continue
|
||||
events_total += len(messaging_events)
|
||||
|
||||
for event in messaging_events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
msg = event.get("message") or {}
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
|
||||
sender_id = str((event.get("sender") or {}).get("id") or "").strip() or None
|
||||
recipient_id = str((event.get("recipient") or {}).get("id") or "").strip() or None
|
||||
ts_ms = int(event.get("timestamp") or 0) if str(event.get("timestamp") or "").isdigit() else None
|
||||
is_echo = bool(msg.get("is_echo"))
|
||||
mid = str(msg.get("mid") or "").strip() or None
|
||||
text = msg.get("text")
|
||||
if text is not None:
|
||||
text = str(text)
|
||||
|
||||
# Normalize "user id" as the non-page side of the thread.
|
||||
user_id = (recipient_id if is_echo else sender_id) or ""
|
||||
direction = "outbound" if is_echo else "inbound"
|
||||
|
||||
if not user_id:
|
||||
continue
|
||||
|
||||
event_id = self.server.store.insert_event(
|
||||
user_id=user_id,
|
||||
sender_id=sender_id,
|
||||
recipient_id=recipient_id,
|
||||
mid=mid,
|
||||
ts_ms=ts_ms,
|
||||
direction=direction,
|
||||
is_echo=is_echo,
|
||||
text=text,
|
||||
raw_event=event,
|
||||
)
|
||||
|
||||
if is_echo:
|
||||
outbound_total += 1
|
||||
matched = self.server.store.link_outbound_to_latest_draft(user_id=user_id, outbound_event_id=event_id)
|
||||
if matched:
|
||||
linked_total += 1
|
||||
continue
|
||||
|
||||
inbound_total += 1
|
||||
if not mid or not text:
|
||||
continue
|
||||
|
||||
try:
|
||||
_triage, _draft, _panel = process_inbound_message(
|
||||
store=self.server.store,
|
||||
reply_lib=self.server.reply_lib,
|
||||
user_id=user_id,
|
||||
mid=mid,
|
||||
ts_ms=ts_ms,
|
||||
text=text,
|
||||
inbound_event_id=event_id,
|
||||
)
|
||||
drafts_total += 1
|
||||
except Exception as e:
|
||||
print(f"igdm: process error mid={mid} user={user_id}: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
# Meta requires a fast 200 OK response.
|
||||
_text(self, 200, "OK")
|
||||
|
||||
# Log summary after responding.
|
||||
try:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "igdm_webhook",
|
||||
"events": events_total,
|
||||
"inbound": inbound_total,
|
||||
"outbound": outbound_total,
|
||||
"drafts": drafts_total,
|
||||
"linked": linked_total,
|
||||
}
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
|
||||
# Keep logs quiet; we print structured summaries ourselves.
|
||||
return
|
||||
|
||||
|
||||
class IgdmShadowServer(ThreadingHTTPServer):
|
||||
def __init__(self, server_address: tuple[str, int], handler: type[BaseHTTPRequestHandler], *, cfg: dict[str, str]):
|
||||
super().__init__(server_address, handler)
|
||||
self.cfg = cfg
|
||||
self.store = IgdmStore(cfg["db_path"])
|
||||
self.reply_lib = ReplyLibrary.load(cfg["reply_library"])
|
||||
self._ready = threading.Event()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description="IGDM Shadow Mode server (Meta webhook + draft-only comparisons).")
|
||||
ap.add_argument("--host", default=_env("IGDM_LISTEN_HOST", "127.0.0.1"))
|
||||
ap.add_argument("--port", type=int, default=int(_env("IGDM_LISTEN_PORT", "5051")))
|
||||
ap.add_argument("--db", default=_env("IGDM_DB_PATH", "/opt/if-emotion/data/igdm/igdm.sqlite"))
|
||||
ap.add_argument(
|
||||
"--reply-library",
|
||||
default=_env("IGDM_REPLY_LIBRARY_PATH", "/opt/igdm-shadow/reply_library/top20_ready_answers.json"),
|
||||
)
|
||||
ap.add_argument("--verify-token", default=_env("META_VERIFY_TOKEN", ""))
|
||||
ap.add_argument("--app-secret", default=_env("META_APP_SECRET", ""))
|
||||
ap.add_argument("--meta-app-id", default=_env("META_APP_ID", ""))
|
||||
ap.add_argument("--graph-api-version", default=_env("META_GRAPH_API_VERSION", "v24.0"))
|
||||
ap.add_argument("--ig-redirect-uri", default=_env("IGDM_IG_REDIRECT_URI", ""))
|
||||
ap.add_argument(
|
||||
"--ig-scopes",
|
||||
default=_env("IGDM_IG_SCOPES", "instagram_business_basic,instagram_business_manage_messages"),
|
||||
)
|
||||
ap.add_argument("--ig-subscribed-fields", default=_env("IGDM_IG_SUBSCRIBED_FIELDS", "messages"))
|
||||
ap.add_argument("--ig-token-path", default=_env("IGDM_IG_TOKEN_PATH", "/opt/if-emotion/data/igdm/ig_token.json"))
|
||||
ap.add_argument("--ig-state-path", default=_env("IGDM_IG_STATE_PATH", "/opt/if-emotion/data/igdm/ig_oauth_state.json"))
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if not args.verify_token:
|
||||
print("Missing META_VERIFY_TOKEN.", file=sys.stderr)
|
||||
return 2
|
||||
if not args.app_secret:
|
||||
print("Missing META_APP_SECRET.", file=sys.stderr)
|
||||
return 2
|
||||
if not args.meta_app_id:
|
||||
print("Missing META_APP_ID (needed for Instagram Business Login connect).", file=sys.stderr, flush=True)
|
||||
|
||||
reply_path = Path(args.reply_library)
|
||||
if not reply_path.exists():
|
||||
print(f"Missing reply library file: {reply_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
Path(args.db).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cfg = {
|
||||
"db_path": str(args.db),
|
||||
"reply_library": str(reply_path),
|
||||
"verify_token": str(args.verify_token),
|
||||
"app_secret": str(args.app_secret),
|
||||
"meta_app_id": str(args.meta_app_id),
|
||||
"graph_api_version": str(args.graph_api_version),
|
||||
"ig_redirect_uri": str(args.ig_redirect_uri),
|
||||
"ig_scopes": str(args.ig_scopes),
|
||||
"ig_subscribed_fields": str(args.ig_subscribed_fields),
|
||||
"ig_token_path": str(args.ig_token_path),
|
||||
"ig_state_path": str(args.ig_state_path),
|
||||
}
|
||||
|
||||
httpd = IgdmShadowServer((args.host, int(args.port)), IgdmShadowHandler, cfg=cfg)
|
||||
print(json.dumps({"event": "igdm_start", "host": args.host, "port": args.port, "db": args.db}), flush=True)
|
||||
httpd.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
220
sergio_instagram_messaging/meta_setup_igdm.py
Normal file
220
sergio_instagram_messaging/meta_setup_igdm.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .export_meta_ig_history import DEFAULT_CREDS_PATH, GraphApiClient, GraphApiConfig, GraphApiError, load_dotenv
|
||||
from .meta_device_login import DEFAULT_STATE_PATH, cmd_poll, cmd_start
|
||||
|
||||
|
||||
RECOMMENDED_SCOPE = (
|
||||
"pages_show_list,"
|
||||
"pages_read_engagement,"
|
||||
"business_management,"
|
||||
"instagram_basic,"
|
||||
"instagram_manage_messages,"
|
||||
"pages_manage_metadata,"
|
||||
"pages_messaging"
|
||||
)
|
||||
|
||||
|
||||
def _redact(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
k: ("<redacted>" if k in {"access_token", "token"} else _redact(v))
|
||||
for k, v in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [_redact(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _write_dotenv_kv(path: str, updates: dict[str, str]) -> None:
|
||||
p = Path(path)
|
||||
lines: list[str] = []
|
||||
if p.exists():
|
||||
lines = p.read_text(errors="replace").splitlines()
|
||||
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for raw in lines:
|
||||
line = raw.rstrip("\n")
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
out.append(line)
|
||||
continue
|
||||
key, _value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
if key in updates:
|
||||
out.append(f"{key}={updates[key]}")
|
||||
seen.add(key)
|
||||
else:
|
||||
out.append(line)
|
||||
|
||||
for key, value in updates.items():
|
||||
if key in seen:
|
||||
continue
|
||||
out.append(f"{key}={value}")
|
||||
|
||||
tmp = p.with_name(p.name + ".tmp")
|
||||
tmp.write_text("\n".join(out).rstrip("\n") + "\n")
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def _require(env: dict[str, str], key: str) -> str:
|
||||
value = (env.get(key) or "").strip()
|
||||
if not value:
|
||||
raise KeyError(f"Missing required config: {key}")
|
||||
return value
|
||||
|
||||
|
||||
def _optional(env: dict[str, str], key: str, default: str) -> str:
|
||||
value = (env.get(key) or "").strip()
|
||||
return value or default
|
||||
|
||||
|
||||
def _discover_page_token(*, user_token: str, version: str, page_id: str) -> str:
|
||||
client = GraphApiClient(GraphApiConfig(access_token=user_token, version=version))
|
||||
payload = client.get_json(
|
||||
"/me/accounts",
|
||||
{
|
||||
"fields": "id,name,access_token",
|
||||
"limit": "200",
|
||||
},
|
||||
)
|
||||
data = payload.get("data") or []
|
||||
if not isinstance(data, list) or not data:
|
||||
raise SystemExit("No pages returned from /me/accounts for this user token.")
|
||||
|
||||
for page in data:
|
||||
if not isinstance(page, dict):
|
||||
continue
|
||||
if str(page.get("id") or "").strip() != page_id:
|
||||
continue
|
||||
token = str(page.get("access_token") or "").strip()
|
||||
if not token:
|
||||
raise SystemExit("Selected page did not include an access_token in /me/accounts response.")
|
||||
return token
|
||||
|
||||
raise SystemExit(f"Page id not found in /me/accounts: {page_id}")
|
||||
|
||||
|
||||
def _subscribe_page(*, page_id: str, page_token: str, version: str, fields: str) -> dict[str, Any]:
|
||||
url = f"https://graph.facebook.com/{version}/{page_id}/subscribed_apps"
|
||||
body = urllib.parse.urlencode(
|
||||
{
|
||||
"subscribed_fields": fields,
|
||||
"access_token": page_token,
|
||||
}
|
||||
).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except Exception:
|
||||
payload = {"raw": raw[:1000]}
|
||||
raise GraphApiError({"status": e.code, "error": payload})
|
||||
|
||||
|
||||
def cmd_wizard(
|
||||
*,
|
||||
creds_path: str,
|
||||
state_path: str,
|
||||
scope: str,
|
||||
page_id: str,
|
||||
subscribed_fields: str,
|
||||
max_wait_s: int,
|
||||
) -> int:
|
||||
env = load_dotenv(creds_path)
|
||||
version = _optional(env, "META_GRAPH_API_VERSION", "v24.0")
|
||||
|
||||
print("Starting Meta device login (no tokens printed).")
|
||||
cmd_start(creds_path=creds_path, state_path=state_path, scope=scope)
|
||||
print("\nWaiting for authorization...")
|
||||
cmd_poll(
|
||||
creds_path=creds_path,
|
||||
state_path=state_path,
|
||||
write_page_token=False,
|
||||
target_ig_user_id=None,
|
||||
max_wait_s=max_wait_s,
|
||||
)
|
||||
|
||||
env = load_dotenv(creds_path)
|
||||
user_token = (env.get("META_USER_ACCESS_TOKEN_LONG") or env.get("META_USER_ACCESS_TOKEN") or "").strip()
|
||||
if not user_token:
|
||||
raise SystemExit("Device login completed but META_USER_ACCESS_TOKEN(_LONG) was not written to creds file.")
|
||||
|
||||
page_token = _discover_page_token(user_token=user_token, version=version, page_id=page_id)
|
||||
_write_dotenv_kv(creds_path, {"META_PAGE_ID": page_id, "META_PAGE_ACCESS_TOKEN": page_token})
|
||||
print("Saved META_PAGE_ID + META_PAGE_ACCESS_TOKEN to creds file (no values printed).")
|
||||
|
||||
try:
|
||||
resp = _subscribe_page(
|
||||
page_id=page_id,
|
||||
page_token=page_token,
|
||||
version=version,
|
||||
fields=subscribed_fields,
|
||||
)
|
||||
print(f"Subscribed Page to fields={subscribed_fields}: {json.dumps(_redact(resp), ensure_ascii=False)}")
|
||||
except GraphApiError as e:
|
||||
payload = e.payload if hasattr(e, "payload") else (e.args[0] if e.args else {"error": str(e)})
|
||||
print(json.dumps(_redact(payload), ensure_ascii=False), file=sys.stderr)
|
||||
return 3
|
||||
|
||||
print("Next: send a real DM to @socialmediatorr and refresh https://emo-social.infrafabric.io/igdm")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=(
|
||||
"One-command wizard to enable IGDM shadow mode delivery.\n\n"
|
||||
"What it does:\n"
|
||||
"1) Starts Meta device login (prints URL + user code)\n"
|
||||
"2) Polls until you authorize\n"
|
||||
"3) Saves a fresh Page token into the creds file (no values printed)\n"
|
||||
"4) Subscribes the Page to messages webhooks\n"
|
||||
)
|
||||
)
|
||||
ap.add_argument("--creds", default=DEFAULT_CREDS_PATH)
|
||||
ap.add_argument("--state", default=DEFAULT_STATE_PATH)
|
||||
ap.add_argument("--scope", default=RECOMMENDED_SCOPE)
|
||||
ap.add_argument("--page-id", default="918150581384278")
|
||||
ap.add_argument("--subscribed-fields", default="messages")
|
||||
ap.add_argument("--max-wait-s", type=int, default=900)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
try:
|
||||
return cmd_wizard(
|
||||
creds_path=str(args.creds),
|
||||
state_path=str(args.state),
|
||||
scope=str(args.scope),
|
||||
page_id=str(args.page_id),
|
||||
subscribed_fields=str(args.subscribed_fields),
|
||||
max_wait_s=int(args.max_wait_s),
|
||||
)
|
||||
except KeyError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
return 2
|
||||
except SystemExit as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
return 2
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
ap = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Subscribe the Facebook Page to this Meta app's webhooks.\n\n"
|
||||
"Note: this requires a Page access token that includes the pages_manage_metadata permission."
|
||||
"Note: subscribing to the messages field typically requires a Page access token that includes the pages_messaging permission."
|
||||
)
|
||||
)
|
||||
ap.set_defaults(cmd=None)
|
||||
|
|
@ -103,4 +103,3 @@ def main(argv: list[str] | None = None) -> int:
|
|||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue