פלייבוק חינמי · כלי ב-60 דקות

בוט שנכנס לפגישות ושולח סיכום ומשימות לטלגרם

ההבטחה: בתוך אחר צהריים אחד, לכל פגישת Zoom, Meet או Teams ביומן שלכם מצטרף רושם שקט. כמה דקות אחרי שהפגישה נגמרת מגיעים אליכם לטלגרם סיכום, החלטות, משימות לפי אחראי והצעות להמשך, ושום הצעה לא יוצאת לפני שאישרתם אותה.

  • אביעד גינדי

הבהרה: המלצות כלליות בלבד, לא ייעוץ מקצועי. האחריות לבדוק לפני שימוש היא שלכם. הבוט נראה בפגישה, אבל זה לא מחליף את החובה לומר למשתתפים שמקליטים ולקבל את הסכמתם. הנוסח המלא בסוף.

למי זה מתאים

למי שהיומן שלו מלא בפגישות וידאו, וכל פגישה נגמרת באותה שאלה: מי רושם? מנהלים, יועצים, פרילנסרים שעובדים עם כמה לקוחות במקביל, ראשי צוותים. אם אתם עדיין רושמים ביד בזמן שמישהו מדבר, או שהסיכום נכתב רק כשנזכרים, הערכה הזאת בשבילכם.

צריך נוחות בסיסית עם שרת ופקודות. לא צריך לכתוב קוד: הקוד המלא כאן, בארבעה חלקים, ומעתיקים אותו לקובץ אחד. אם שרת זה צעד גדול מדי בשבילכם כרגע, התחילו מהשלבים הקודמים בסדרה, שעובדים על המחשב שלכם.

מה יהיה לכם בסוף

  • בוט אחד לכל פגישה ביומן, שמתוזמן לבד כל בוקר ונכנס לפגישה דקה לפני שהיא מתחילה.
  • הודעת בוקר בטלגרם עם רשימת הפגישות של היום. תשובה "בלי בוט 2" מבטלת את הבוט לפגישה השנייה.
  • תמליל בעברית עם שמות הדוברים, שנשמר אצלכם. ההקלטה עצמה נמחקת מהשרתים של ספק ההקלטה מיד אחרי ההורדה.
  • סיכום מובנה בטלגרם: סיכום קצר, החלטות, המשימות שלכם, המשימות של אחרים עם אחראי ויעד, ושאלות פתוחות.
  • הצעות להמשך שמחכות לאישור: מייל ללקוח, הודעה לספק, פגישת המשך. "אשר 3" מאשר, "בטל 3" מוריד. שום דבר לא יוצא בלי אישור.
  • אותו תהליך לשיחות טלפון ולפגישות פנים מול פנים, מהקלטה בטלפון.

איך זה בנוי

ארבעה חלקים, ושרת אחד קטן שמחבר ביניהם:

  1. היומן (Google Calendar) אומר איזה פגישות יש היום ואיפה הקישור.
  2. Recall.ai שולח בוט לכל פגישה, מקליט ומתמלל בעברית. זה שירות בתשלום לפי שעה, והמחירים בהמשך.
  3. מודל שפה (Claude, ChatGPT או אחר) הופך את התמליל לסיכום בפורמט קבוע.
  4. טלגרם הוא המקום שבו אתם רואים הכל ומאשרים.

השרת לא פתוח לאינטרנט. הוא לא מחכה שיפנו אליו: כל כמה דקות הוא שואל בעצמו את Recall.ai אם יש פגישה שהסתיימה, ואת טלגרם אם כתבתם משהו. זה פשוט יותר ובטוח יותר מ-webhook, וחמש דקות של המתנה לסיכום לא משנות כלום.

מה צריך

  • שרת קטן (VPS) עם Python 3.11 ומעלה, או מחשב שדלוק כל היום. אם אין לכם שרת מוגדר ומאובטח, התחילו משרת לסוכני AI שלא נופל ולא נפרץ.
  • חשבון Google עם היומן שלכם.
  • חשבון ב-Recall.ai, שבו 5 שעות ההקלטה הראשונות חינם.
  • מפתח API למודל שפה. הקוד כאן משתמש ב-Claude, ואפשר להחליף בשורה אחת לספק אחר.
  • בוט טלגרם (יוצרים אותו ב-BotFather, בחינם) ומספר הצ'אט שלכם.

הערכה, צעד אחרי צעד (אחר צהריים אחד)

צעד 1 (20 דקות): חשבון ב-Recall.ai ומפתח שנשאר בשרת

מה עושים: נרשמים ל-Recall.ai, יוצרים מפתח API ושומרים אותו רק בשרת.

איך:

  1. בהרשמה בוחרים את האזור האירופי (EU, שרתים בפרנקפורט). החשבון והמפתח שייכים לאזור, וכל ההקלטות והתמלילים נשמרים בו. לעסק בישראל זה בדרך כלל עדיף מבחינת פרטיות על שרתים בארה"ב.
  2. בלוח הבקרה יוצרים API Key. מעתיקים אותו פעם אחת.
  3. בשרת יוצרים תיקייה, למשל ~/meeting-bot, ובתוכה קובץ .env עם ההגדרות. הקובץ לא נכנס ל-Git ולא נשלח לאף אחד.
קובץ .env (ממלאים את הערכים שלכם)
RECALL_API_KEY=המפתח-מ-Recall
RECALL_BASE=https://eu-central-1.recall.ai
ANTHROPIC_API_KEY=המפתח-של-מודל-השפה
TELEGRAM_TOKEN=הטוקן-מ-BotFather
TELEGRAM_CHAT_ID=מספר-הצ'אט-שלכם
GOOGLE_KEY_FILE=/home/ops/meeting-bot/google-key.json
CALENDAR_ID=you@example.com
MY_NAME=השם שלכם כפי שהוא מופיע בפגישות
BOT_NAME=Notetaker
BOT_LANG=he
להעתקה
chmod 600 ~/meeting-bot/.env
pip install requests anthropic google-api-python-client google-auth

טעות נפוצה: להדביק את המפתח בתוך הקוד, ואז להעלות את הקוד ל-GitHub. מפתח שדלף מאפשר לכל אחד לשלוח בוטים על החשבון שלכם. אם זה קרה, מוחקים את המפתח בלוח הבקרה ויוצרים חדש.

צעד 2 (30 דקות): קוראים את היומן ומוצאים את הקישור

מה עושים: נותנים לשרת הרשאת קריאה ליומן, ומלמדים אותו למצוא את קישור הפגישה בכל אירוע.

איך:

  1. ב-Google Cloud Console יוצרים פרויקט, מפעילים את Google Calendar API, ויוצרים Service Account. מורידים את קובץ המפתח שלו (JSON) לשרת, לנתיב שכתבתם ב-GOOGLE_KEY_FILE, ונותנים לו chmod 600.
  2. ב-Google Calendar, בהגדרות היומן שלכם, משתפים אותו עם האימייל של ה-Service Account, בהרשאה "הצגת כל פרטי האירועים". רק קריאה, בלי עריכה.
  3. יוצרים קובץ meeting_bot.py ומדביקים בו את חלק 1.

קישור לפגישה יכול להופיע בארבעה מקומות: hangoutLink (פגישות Meet), conferenceData (Meet, ותוספים של Zoom ו-Teams), שדה המיקום, ותיאור האירוע, לפעמים בתוך HTML. הקוד בודק אותם בסדר הזה ומחזיר את הקישור הראשון שהוא מוצא.

meeting_bot.py, חלק 1: הגדרות, טקסטים וקישורי פגישות
# meeting_bot.py, part 1 of 4: settings, texts and meeting links
import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

RECALL_BASE = os.environ.get("RECALL_BASE", "https://eu-central-1.recall.ai")
RECALL_KEY = os.environ.get("RECALL_API_KEY", "")
BOT_NAME = os.environ.get("BOT_NAME", "Notetaker")
LANG = os.environ.get("BOT_LANG", "he")
STATE_FILE = Path(os.environ.get("STATE_FILE", "state.json"))
TRANSCRIPTS = Path(os.environ.get("TRANSCRIPTS_DIR", "transcripts"))
SKIP_TAGS = ("#ללא-בוט", "#no-bot")

TEXT = {
    "he": {
        "today": "הפגישות של היום",
        "tagged": "בלי בוט (מסומן)",
        "cancel_hint": "לביטול: בלי בוט <מספר>",
        "none": "אין היום פגישות עם קישור.",
        "cancelled": "בוטל הבוט לפגישה {n}.",
        "failed": "הבוט לא הצליח להקליט את {title}.",
        "summary": "סיכום",
        "decisions": "החלטות",
        "mine": "משימות שלי",
        "theirs": "משימות של אחרים",
        "follow_ups": "הצעות להמשך (שום דבר לא יוצא בלי אישור)",
        "questions": "שאלות פתוחות",
        "no_due": "בלי יעד",
        "approve_hint": "לאישור: אשר <מספר> · לביטול: בטל <מספר>",
        "approved": "אושר {n}. הנוסח הסופי:",
        "dropped": "בוטל {n}.",
    },
    "en": {
        "today": "Today's meetings",
        "tagged": "no bot (tagged)",
        "cancel_hint": "To cancel: no bot <number>",
        "none": "No meetings with a link today.",
        "cancelled": "Bot for meeting {n} cancelled.",
        "failed": "The bot could not record {title}.",
        "summary": "Summary",
        "decisions": "Decisions",
        "mine": "My tasks",
        "theirs": "Other people's tasks",
        "follow_ups": "Proposed follow-ups (nothing goes out without approval)",
        "questions": "Open questions",
        "no_due": "no due date",
        "approve_hint": "To approve: approve <number> · to drop: drop <number>",
        "approved": "Approved {n}. The final text:",
        "dropped": "Dropped {n}.",
    },
}
T = TEXT["en" if LANG == "en" else "he"]

# Zoom, Google Meet and Teams links, wherever the invite put them
MEETING_LINK = re.compile(
    r"https://(?:"
    r"(?:[\w-]+\.)?zoom\.us/(?:j|my|w)/[\w.?=&%/-]+"
    r"|meet\.google\.com/[a-z]{3}-[a-z]{4}-[a-z]{3}"
    r"|teams\.microsoft\.com/l/meetup-join/[^\s\"'<>]+"
    r"|teams\.live\.com/meet/[^\s\"'<>]+"
    r")",
    re.IGNORECASE,
)


def extract_meeting_url(event):
    """The video link of a Google Calendar event, or None."""
    if event.get("hangoutLink"):
        return event["hangoutLink"]
    for ep in (event.get("conferenceData") or {}).get("entryPoints", []):
        if ep.get("entryPointType") == "video" and ep.get("uri"):
            return ep["uri"]
    for field in ("location", "description"):
        m = MEETING_LINK.search(event.get(field) or "")
        if m:
            return m.group(0).rstrip(").,;")
    return None


def should_skip(event):
    text = " ".join(event.get(k) or "" for k in ("summary", "description"))
    return any(tag in text for tag in SKIP_TAGS)


def load_state():
    if STATE_FILE.exists():
        return json.loads(STATE_FILE.read_text(encoding="utf-8"))
    return {"events": {}, "bots": {}, "items": {}, "next_item": 1, "tg_offset": 0}


def save_state(state):
    STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=1), encoding="utf-8")

לפני שסומכים על הקוד, בודקים שבוע של יומן. מייצאים את האירועים של השבוע (כותרת, שעה, מיקום ותיאור) ומריצים את הפרומפט הזה בכלי AI. הוא מראה לכם איך ההזמנות שלכם באמת נראות, ואם יש פורמט שהקוד לא תופס.

1. פרומפט: חילוץ קישורי פגישות מהיומן
אתה עוזר שבודק יומן לפני שמחברים אליו בוט שמקליט פגישות.
לפניך רשימת אירועים (כותרת | שעה | מיקום | תיאור):
[הדביקו את האירועים]

לכל אירוע החזר שורה ב-JSON בלבד, בלי טקסט נוסף:
{"title":"","start":"","platform":"zoom|meet|teams|phone|in_person|none","meeting_url":"","skip":false,"reason":""}

כללים:
1. meeting_url: רק קישור שמופיע בטקסט, מילה במילה. אל תשלים ואל תנחש קישור.
2. platform: "phone" אם יש רק מספר טלפון, "in_person" אם יש כתובת בלי קישור, "none" אם אין כלום.
3. skip=true אם בכותרת או בתיאור מופיע #ללא-בוט, או אם הפגישה נראית רגישה (ראיון עבודה, רופא, עורך דין, שיחה אישית). כתוב ב-reason למה.
4. בסוף, שורה אחת: כמה אירועים עם קישור, כמה בלי, וכמה סומנו לדילוג.

צעד 3 (30 דקות): בוט לכל פגישה, ורשימת בוקר בטלגרם

מה עושים: כל בוקר השרת עובר על הפגישות של היום, ושולח ל-Recall.ai בקשה אחת לכל פגישה: איזה קישור, מתי להיכנס, באיזה שם, ובאיזו שפה לתמלל.

איך: מדביקים את חלק 2 מתחת לחלק 1. הבקשה שולחת POST /api/v1/bot/ עם:

  • meeting_url: הקישור מצעד 2.
  • join_at: דקה לפני תחילת הפגישה. בוט שמתוזמן יותר מעשר דקות מראש מובטח שיהיה זמין, ולכן מתזמנים בבוקר ולא ברגע האחרון.
  • bot_name: השם שהמשתתפים יראו. "Notetaker" או "הרושם של דנה" עדיף על שם שנראה כמו אדם.
  • תמלול מובנה של Recall.ai (recallai_streaming) במצב prioritize_accuracy, עם language_code "he". מצב הדיוק איטי יותר מהמצב המהיר, וזה בסדר: הסיכום נכתב אחרי הפגישה, לא בזמן אמת.

פגישה שמסומנת #ללא-בוט בכותרת או בתיאור לא מקבלת בוט, והיא מופיעה ברשימה כדי שתראו שהסימון עבד. הקוד זוכר איזה אירוע כבר קיבל בוט, כך שהרצה חוזרת לא שולחת שני בוטים לאותה פגישה.

meeting_bot.py, חלק 2: היומן ובוט לכל פגישה
# meeting_bot.py, part 2 of 4: the calendar and one bot per meeting
import requests


def recall(method, path, **kwargs):
    r = requests.request(method, RECALL_BASE + path, timeout=60,
                         headers={"Authorization": "Token " + RECALL_KEY}, **kwargs)
    r.raise_for_status()
    return r.json() if r.content else {}


def todays_events():
    """Timed events from now until the end of the day (needs a service account key)."""
    from google.oauth2 import service_account
    from googleapiclient.discovery import build

    creds = service_account.Credentials.from_service_account_file(
        os.environ["GOOGLE_KEY_FILE"],
        scopes=["https://www.googleapis.com/auth/calendar.readonly"])
    service = build("calendar", "v3", credentials=creds, cache_discovery=False)
    now = datetime.now(timezone.utc)
    res = service.events().list(
        calendarId=os.environ["CALENDAR_ID"], timeMin=now.isoformat(),
        timeMax=(now + timedelta(hours=18)).isoformat(),
        singleEvents=True, orderBy="startTime").execute()
    return [e for e in res.get("items", []) if "dateTime" in e.get("start", {})]


def schedule_bot(event, url):
    start = datetime.fromisoformat(event["start"]["dateTime"])
    bot = recall("POST", "/api/v1/bot/", json={
        "meeting_url": url,
        "bot_name": BOT_NAME,
        "join_at": (start - timedelta(minutes=1)).astimezone(timezone.utc).isoformat(),
        "recording_config": {"transcript": {"provider": {"recallai_streaming": {
            "mode": "prioritize_accuracy", "language_code": "he"}}}},
    })
    return bot["id"]


def plan_day(send_list):
    """morning: schedule and send the list. sync: schedule only what was added since."""
    state = load_state()
    today = datetime.now().strftime("%Y-%m-%d")
    lines = []
    for ev in todays_events():
        url = extract_meeting_url(ev)
        start = datetime.fromisoformat(ev["start"]["dateTime"])
        if not url or start <= datetime.now(timezone.utc):
            continue
        title = ev.get("summary", "")
        if should_skip(ev):
            lines.append(f"- {start:%H:%M} {title}: {T['tagged']}")
            continue
        if ev["id"] not in state["events"]:
            n = 1 + sum(1 for b in state["bots"].values() if b["day"] == today)
            bot_id = schedule_bot(ev, url)
            state["events"][ev["id"]] = bot_id
            state["bots"][bot_id] = {"n": n, "day": today, "title": title,
                                     "start": ev["start"]["dateTime"], "status": "scheduled"}
        b = state["bots"][state["events"][ev["id"]]]
        lines.append(f"{b['n']}. {start:%H:%M} {title}")
    save_state(state)
    if send_list:
        tg("\n".join([T["today"], ""] + lines + ["", T["cancel_hint"]]) if lines else T["none"])

טעות נפוצה: להריץ את הבוקר רק פעם אחת. פגישה שנקבעה בצהריים לא תקבל בוט. לכן יש שתי פקודות: morning שולחת את הרשימה, ו-sync רצה כל שעה בשקט ומתזמנת רק את מה שנוסף. פגישה שנקבעה פחות מעשר דקות לפני שהיא מתחילה עלולה להישאר בלי בוט.

צעד 4 (20 דקות): אחרי הפגישה, תמליל ומחיקה

מה עושים: כל חמש דקות השרת שואל את Recall.ai מה המצב של כל בוט פתוח. כשהפגישה נגמרה והתמליל מוכן, הוא מוריד את התמליל עם שמות הדוברים, ומוחק את ההקלטה אצל Recall.ai.

איך: מדביקים את חלק 3. מה שקורה בו:

  1. GET /api/v1/bot/{id}/ מחזיר את רשימת השינויים במצב הבוט. המצב האחרון done אומר שהפגישה נגמרה וההקלטה עובדה. fatal אומר שהבוט לא הצליח (למשל, אף אחד לא הכניס אותו מחדר ההמתנה), ואז מגיעה אליכם הודעה.
  2. התמליל מגיע כ-JSON: לכל קטע, שם הדובר והמילים עם זמנים. הקוד הופך אותו לשורות בפורמט [mm:ss] שם: טקסט ושומר עותק בתיקייה transcripts אצלכם.
  3. POST /api/v1/bot/{id}/delete_media/ מוחק את ההקלטה והתמליל אצל Recall.ai. אחרי זה אין מה לשלם על אחסון, ואין עותק של הפגישה אצל צד שלישי.

למה ככה: אין כאן webhook, ולכן אין כתובת ציבורית שצריך להגן עליה. השרת יוצא החוצה, אף אחד לא נכנס פנימה. המחיר הוא עד חמש דקות של המתנה לסיכום.

meeting_bot.py, חלק 3: תמליל, סיכום ומחיקה
# meeting_bot.py, part 3 of 4: after the meeting, transcript, summary, delete
def bot_status(bot):
    changes = bot.get("status_changes") or []
    return changes[-1]["code"] if changes else "unknown"


def fetch_transcript(bot):
    for rec in bot.get("recordings") or []:
        tr = (rec.get("media_shortcuts") or {}).get("transcript") or {}
        if (tr.get("status") or {}).get("code") == "done":
            return requests.get(tr["data"]["download_url"], timeout=120).json()
    return None


def transcript_to_text(transcript):
    """One line per speaker turn: [mm:ss] Name: text"""
    lines = []
    for part in transcript:
        words = part.get("words") or []
        if not words:
            continue
        name = (part.get("participant") or {}).get("name") or "?"
        sec = int((words[0].get("start_timestamp") or {}).get("relative") or 0)
        lines.append(f"[{sec // 60:02d}:{sec % 60:02d}] {name}: " + " ".join(w["text"] for w in words))
    return "\n".join(lines)


def parse_json(text):
    s = text.strip()
    a, b = s.find("{"), s.rfind("}")
    if a < 0 or b <= a:
        raise ValueError("no JSON object in the answer")
    return json.loads(s[a:b + 1])


def _date_or_empty(v):
    v = str(v or "").strip()
    try:
        datetime.strptime(v, "%Y-%m-%d")
        return v
    except ValueError:
        return ""


def _strings(v):
    return [str(x).strip() for x in v if str(x).strip()] if isinstance(v, list) else []


def validate_summary(data):
    """Keeps only what the format allows. No date that is not a real yyyy-mm-dd."""
    if not isinstance(data, dict):
        raise ValueError("the answer is not a JSON object")
    out = {"summary": _strings(data.get("summary")), "decisions": _strings(data.get("decisions")),
           "open_questions": _strings(data.get("open_questions")),
           "tasks": {"mine": [], "theirs": []}, "follow_ups": []}
    tasks = data.get("tasks") if isinstance(data.get("tasks"), dict) else {}
    for side in ("mine", "theirs"):
        for t in tasks.get(side) or []:
            if isinstance(t, dict) and str(t.get("task") or "").strip():
                out["tasks"][side].append({
                    "task": str(t["task"]).strip(), "owner": str(t.get("owner") or "").strip(),
                    "due": _date_or_empty(t.get("due")), "quote": str(t.get("quote") or "").strip()})
    for f in data.get("follow_ups") or []:
        if isinstance(f, dict) and f.get("kind") in ("email", "message", "meeting") and str(f.get("draft") or "").strip():
            out["follow_ups"].append({k: str(f.get(k) or "").strip() for k in ("kind", "to", "subject", "draft", "why")})
    if not out["summary"]:
        raise ValueError("empty summary")
    return out


def summarize(transcript_text, title, date):
    import anthropic

    prompt = Path("summary_prompt.txt").read_text(encoding="utf-8")
    for key, value in (("{{TITLE}}", title), ("{{DATE}}", date),
                       ("{{ME}}", os.environ.get("MY_NAME", "")), ("{{TRANSCRIPT}}", transcript_text)):
        prompt = prompt.replace(key, value)
    client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY
    msg = client.beta.messages.create(
        model=os.environ.get("LLM_MODEL", "claude-opus-5-5"), max_tokens=16000,
        betas=["server-side-fallback-2026-07-01"], fallbacks="default",
        messages=[{"role": "user", "content": prompt}])
    if msg.stop_reason == "refusal":
        raise RuntimeError("the model declined this transcript")
    return validate_summary(parse_json("".join(b.text for b in msg.content if b.type == "text")))


def finish(state, title, date, text):
    TRANSCRIPTS.mkdir(exist_ok=True)
    (TRANSCRIPTS / f"{date} {title[:40]}.txt".replace("/", "-")).write_text(text, encoding="utf-8")
    send_summary(state, title, summarize(text, title, date))


def collect(state):
    for bot_id, b in state["bots"].items():
        if b["status"] != "scheduled":
            continue
        bot = recall("GET", f"/api/v1/bot/{bot_id}/")
        if bot_status(bot) == "fatal":
            b["status"] = "failed"
            tg(T["failed"].format(title=b["title"]))
            continue
        if bot_status(bot) != "done":
            continue
        transcript = fetch_transcript(bot)
        if transcript is None:
            continue  # still processing, try on the next run
        finish(state, b["title"], b["start"][:10], transcript_to_text(transcript))
        recall("POST", f"/api/v1/bot/{bot_id}/delete_media/")
        b["status"] = "done"
        save_state(state)

צעד 5 (30 דקות): סיכום ב-JSON קבוע, ובדיקה שלו

מה עושים: שומרים את הפרומפט הזה בקובץ summary_prompt.txt ליד הקוד. הקוד מכניס במקום {{TRANSCRIPT}} את התמליל, שולח למודל, ובודק שהתשובה בפורמט הנכון לפני שהיא מגיעה אליכם.

2. פרומפט: מתמליל לסיכום, משימות והצעות (summary_prompt.txt)
אתה מזכיר פגישות מדויק. קרא את התמליל והחזר JSON אחד בלבד, בלי טקסט לפניו או אחריו.

כותרת הפגישה: {{TITLE}}
תאריך הפגישה: {{DATE}}
אני (מי שהבוט עובד בשבילו): {{ME}}

התמליל (שורה לכל דובר, עם זמן):
{{TRANSCRIPT}}

כללים מחייבים:
1. summary: עד 6 נקודות קצרות. מה הוחלט ומה השתנה, לא מי אמר מה.
2. decisions: רק דברים שסוכמו במפורש. הצעה שאף אחד לא אישר היא לא החלטה.
3. tasks.mine: משימות שאני התחייבתי אליהן או שביקשו ממני. tasks.theirs: משימות של אחרים, עם owner בשם כפי שנאמר בפגישה.
4. due: רק תאריך שנאמר או שאפשר לחשב בוודאות מתאריך הפגישה ("עד יום חמישי"), בפורמט yyyy-mm-dd. אחרת "".
5. quote: המשפט מהתמליל שבו סוכמה המשימה, מילה במילה.
6. follow_ups: עד 4 פעולות המשך שאני כנראה ארצה לעשות: מייל (email), הודעה (message) או פגישת המשך (meeting). לכל אחת: to, subject, draft (הנוסח המלא, מוכן לשליחה, בשפה של הפגישה) ו-why (משפט אחד: למה, עם הפניה למה שנאמר).
7. אל תמציא שמות, סכומים, תאריכים או הבטחות שלא נאמרו. מה שלא ברור הולך ל-open_questions.
8. אל תכלול מספרי חשבון, תעודות זהות, סיסמאות או פרטים רפואיים, גם אם נאמרו.

המבנה:
{"meeting":{"title":"","date":""},"summary":[],"decisions":[],"tasks":{"mine":[{"task":"","owner":"","due":"","quote":""}],"theirs":[{"task":"","owner":"","due":"","quote":""}]},"follow_ups":[{"kind":"email","to":"","subject":"","draft":"","why":""}],"open_questions":[]}

איך הבדיקה עובדת: validate_summary בחלק 3 לא סומך על המודל. היא זורקת משימה בלי תוכן, מוחקת תאריך שאינו תאריך אמיתי (למשל 30 בפברואר), מורידה הצעת המשך מסוג לא מוכר או בלי נוסח, ונכשלת אם אין סיכום בכלל. כשהבדיקה נכשלת, ההקלטה לא נמחקת והשרת מנסה שוב בסבב הבא, כך ששום פגישה לא הולכת לאיבוד בגלל תשובה עקומה.

על מודל השפה: הקוד משתמש ב-Claude דרך ה-API של Anthropic, עם גיבוי אוטומטי למודל אחר אם המודל הראשי מסרב לתמליל. אפשר להחליף את הפונקציה summarize בכל ספק אחר: הפרומפט והבדיקה נשארים אותו דבר.

טעות נפוצה: לבקש "תסכם את הפגישה" בלי מבנה קבוע. מקבלים טקסט יפה שאי אפשר לעבד: אי אפשר לספור משימות, לאשר הצעה לפי מספר, או לזהות שחסר יעד.

צעד 6 (20 דקות): טלגרם, והצעות שמחכות לאישור

מה עושים: מדביקים את חלק 4, מחברים את הפקודות ל-cron, ובודקים בפגישת ניסיון.

איך:

  1. ב-BotFather יוצרים בוט, שולחים לו הודעה אחת, ומוצאים את מספר הצ'אט שלכם (למשל דרך getUpdates). מכניסים את שניהם ל-.env.
  2. הבוט מקבל פקודות רק מהצ'אט שלכם. הודעה מכל צ'אט אחר נזרקת.
  3. כל הצעת המשך מקבלת מספר. "אשר 3" מסמן אותה כמאושרת ומפעיל את run_follow_up. "בטל 3" מוריד אותה.
  4. כמו שהקוד כתוב, אישור שולח לכם את הנוסח הסופי, ואתם מדביקים ושולחים. כשאתם בטוחים בתהליך, אפשר לחבר ל-run_follow_up את המייל או היומן שלכם. גם אז, רק מה שאישרתם יוצא.
חלק 4: טלגרם, אישורים והפקודות
# meeting_bot.py, part 4 of 4: Telegram, approvals and the commands
TG = "https://api.telegram.org/bot" + os.environ.get("TELEGRAM_TOKEN", "")
OWNER = str(os.environ.get("TELEGRAM_CHAT_ID", ""))
COMMAND = re.compile(r"^\s*(בלי בוט|no bot|אשר|approve|בטל|drop)\s+(\d+)\s*$", re.IGNORECASE)


def tg(text):
    for i in range(0, len(text), 4000):  # Telegram's limit is 4096 characters
        requests.post(TG + "/sendMessage", json={"chat_id": OWNER, "text": text[i:i + 4000]},
                      timeout=30).raise_for_status()


def format_summary(title, data, first_item):
    out = [title, "", T["summary"]] + [f"- {s}" for s in data["summary"]]
    if data["decisions"]:
        out += ["", T["decisions"]] + [f"- {s}" for s in data["decisions"]]
    for side in ("mine", "theirs"):
        if data["tasks"][side]:
            out += ["", T[side]]
            for t in data["tasks"][side]:
                who = f"{t['owner']}: " if side == "theirs" else ""
                out.append(f"- {who}{t['task']} ({t['due'] or T['no_due']})")
    if data["follow_ups"]:
        out += ["", T["follow_ups"]]
        for i, f in enumerate(data["follow_ups"]):
            out.append(f"{first_item + i}. {f['kind']} → {f['to']}: {f['subject']} ({f['why']})")
        out += ["", T["approve_hint"]]
    if data["open_questions"]:
        out += ["", T["questions"]] + [f"- {s}" for s in data["open_questions"]]
    return "\n".join(out)


def send_summary(state, title, data):
    first = state["next_item"]
    for f in data["follow_ups"]:
        state["items"][str(state["next_item"])] = {**f, "status": "waiting"}
        state["next_item"] += 1
    tg(format_summary(title, data, first))


def run_follow_up(item):
    """Runs only after "approve N". Connect your mail or calendar here.
    As written, it sends you the final text so you can paste it and send it yourself."""
    tg(f"{item['kind']} → {item['to']}\n{item['subject']}\n\n{item['draft']}")


def parse_command(text):
    m = COMMAND.match(text or "")
    if not m:
        return None
    word = m.group(1).lower()
    kind = {"בלי בוט": "cancel", "no bot": "cancel", "אשר": "approve", "approve": "approve"}.get(word, "drop")
    return kind, int(m.group(2))


def handle_telegram(state):
    res = requests.get(TG + "/getUpdates", params={"offset": state["tg_offset"], "timeout": 0}, timeout=30).json()
    today = datetime.now().strftime("%Y-%m-%d")
    for upd in res.get("result", []):
        state["tg_offset"] = upd["update_id"] + 1
        msg = upd.get("message") or {}
        if str((msg.get("chat") or {}).get("id")) != OWNER:
            continue  # only you can give commands
        cmd = parse_command(msg.get("text"))
        if not cmd:
            continue
        kind, n = cmd
        if kind == "cancel":
            for bot_id, b in state["bots"].items():
                if b["day"] == today and b["n"] == n and b["status"] == "scheduled":
                    recall("DELETE", f"/api/v1/bot/{bot_id}/")
                    b["status"] = "cancelled"
                    tg(T["cancelled"].format(n=n))
            continue
        item = state["items"].get(str(n))
        if not item or item["status"] != "waiting":
            continue
        item["status"] = "approved" if kind == "approve" else "dropped"
        if kind == "approve":
            tg(T["approved"].format(n=n))
            run_follow_up(item)
        else:
            tg(T["dropped"].format(n=n))
    save_state(state)


def main(cmd, *args):
    if cmd in ("morning", "sync"):
        plan_day(send_list=(cmd == "morning"))
    elif cmd == "poll":
        state = load_state()
        handle_telegram(state)
        collect(state)
    elif cmd == "file":  # a phone or in-person recording, transcribed on your computer
        path, title = Path(args[0]), args[1]
        state = load_state()
        finish(state, title, datetime.now().strftime("%Y-%m-%d"), path.read_text(encoding="utf-8"))
        save_state(state)


if __name__ == "__main__":
    main(*sys.argv[1:])
cron: מה רץ ומתי (crontab -e, לפי שעון השרת)
30 7 * * 0-5  cd ~/meeting-bot && set -a && . ./.env && python3 meeting_bot.py morning
5 8-20 * * 0-5  cd ~/meeting-bot && set -a && . ./.env && python3 meeting_bot.py sync
*/5 7-22 * * 0-5  cd ~/meeting-bot && set -a && . ./.env && python3 meeting_bot.py poll

למה ככה: ההצעות מגיעות עם נוסח מלא ושורת "למה". אתם מאשרים טקסט שראיתם, לא כוונה כללית. אישור אחד לא מאשר את כל השאר.

צעד 7 (15 דקות): שיחות טלפון ופגישות פנים מול פנים

מה עושים: לשיחה בטלפון או לפגישה בחדר אין קישור, ולכן אין בוט. מקליטים בטלפון, ומעבירים את אותה הקלטה לאותו תהליך.

איך:

  1. שיחת טלפון: בטלפונים של Samsung ובחלק ממכשירי האנדרואיד יש הקלטת שיחות מובנית בהגדרות אפליקציית הטלפון. באייפון, בדקו אם האפשרות זמינה אצלכם; אם לא, מפעילים רמקול ומקליטים במכשיר אחר.
  2. פגישה פנים מול פנים: אפליקציית ההקלטה של הטלפון, על השולחן, במרכז.
  3. משתפים את הקובץ למחשב ומתמללים בחינם עם שלב 1 בסדרה. התמליל לא יוצא מהמחשב.
  4. מעבירים את קובץ הטקסט לשרת ומריצים:
להעתקה
python3 meeting_bot.py file "transcripts/2026-11-12 call.txt" "שיחה עם ספק הדפוס"

מכאן הכל זהה: סיכום, משימות והצעות בטלגרם, שמחכות לאישור.

צעד 8 (10 דקות): פרטיות ונימוס

  • הבוט גלוי. הוא נכנס עם שם, וכל המשתתפים רואים אותו ברשימה. זה לא מספיק: אומרים בתחילת הפגישה "יש איתנו רושם אוטומטי, כדי שהסיכום והמשימות יהיו מדויקים. למישהו מפריע?". אם למישהו מפריע, מוציאים את הבוט מהפגישה.
  • בהזמנה עצמה אפשר להוסיף שורה: "בפגישה ישתתף רושם אוטומטי. אם אתם מעדיפים בלי, כתבו לי."
  • פגישות רגישות מקבלות #ללא-בוט: ראיונות עבודה, שיחות עם עורך דין, נושאים רפואיים או אישיים, ופגישות שבהן הצד השני ביקש לא להקליט.
  • ההקלטה נמחקת אצל Recall.ai מיד אחרי ההורדה, והתמליל נשמר רק אצלכם. קבעו גם מתי אתם מוחקים תמלילים מהתיקייה, למשל אחרי 90 יום.
  • בישראל, הקלטה של שיחה ושמירה של מידע על אנשים כפופות לחוק האזנת סתר ולחוק הגנת הפרטיות. הדרך הבטוחה היא הסכמה של כל המשתתפים, ובדיקה עם עורך דין מה חל עליכם.

כמה זה עולה

המחירים נכונים לנובמבר 2026, לפי Recall.ai. מחירים משתנים, אז בדקו את עמוד התמחור שלהם לפני שאתם מתכננים תקציב.

מה מחיר הערות
הקלטה ב-Recall.ai 0.50 דולר לשעה לפי שניות. 5 השעות הראשונות חינם
תמלול מובנה של Recall.ai 0.15 דולר לשעה תומך בעברית
אחסון ב-Recall.ai 7 ימים חינם, אחר כך 0.05 דולר לשעת הקלטה ל-30 יום לא משלמים אם מוחקים אחרי ההורדה, כמו בצעד 4
סיכום במודל שפה כמה סנטים לפגישה דרך API או בלי תוספת, אם מדביקים ידנית במנוי Claude או ChatGPT קיים
סך הכל בערך 0.65 דולר לשעת פגישה (כ-2.4 ש"ח) 20 שעות פגישה בחודש: בערך 13 דולר

אפשר לחסוך את התמלול. במקום התמלול של Recall.ai, מורידים את קובץ השמע ומתמללים עם המודל החינמי של ivrit.ai, כמו בשלב 1. אז העלות יורדת ל-0.50 דולר לשעה. על מחשב רגיל, תמלול של פגישה ארוכה לוקח זמן; עם שרת GPU לפי דרישה (למשל RunPod) זה כמה סנטים לשעה.

להשוואה: מכשיר הקלטה ייעודי לפגישות עולה בדרך כלל מחיר של מכשיר, ועוד מנוי של בערך 20 דולר לחודש. כאן אין מכשיר ואין מנוי קבוע: משלמים רק על שעות שהוקלטו.


כשמשהו לא עובד

מה רואים מה עושים
רשימת הבוקר ריקה בדקו שהיומן משותף עם ה-Service Account, ושב-CALENDAR_ID כתוב האימייל שלכם ולא "primary".
פגישה עם קישור לא קיבלה בוט הריצו את פרומפט 1 על האירוע. אם הקישור בפורמט שהקוד לא מכיר, הוסיפו אותו לביטוי MEETING_LINK.
הבוט נשאר בחדר ההמתנה מישהו צריך להכניס אותו. בפגישות שלכם אפשר לאשר כניסה אוטומטית למשתתפים מבחוץ, או לשים את הבוט ברשימת המוזמנים.
401 מ-Recall.ai המפתח לא נכון, או ש-RECALL_BASE מצביע לאזור אחר מזה שבו נרשמתם.
סיכום לא מגיע אחרי פגישה בדקו את הלוג של poll. אם validate_summary נכשלה, הסבב הבא ינסה שוב, וההקלטה עוד לא נמחקה.
"אשר 3" לא עושה כלום ההצעה כבר אושרה או בוטלה, או שההודעה נשלחה מצ'אט אחר.

דוגמה מלאה: יום עבודה של סטודיו גלים לעיצוב (עסק בדוי)

כל מה שבדוגמה בדוי: הסטודיו, הלקוחות, האנשים והסכומים.

רוני מנהלת סטודיו קטן לעיצוב פנים. ביומן של יום שלישי, 3 בנובמבר, יש שלוש פגישות: פגישת היכרות ב-Meet עם לקוח חדש, ישיבת צוות ב-Zoom, ושיחה עם רואה החשבון ב-Teams שסומנה #ללא-בוט.

07:30, בטלגרם:
הפגישות של היום

1. 09:00 היכרות: דירה בגבעתיים
2. 11:30 ישיבת צוות שבועית
- 15:00 רו"ח, דוחות רבעון: בלי בוט (מסומן)

לביטול: בלי בוט <מספר>

רוני עונה "בלי בוט 2", כי בישיבת הצוות השבוע מדברים על משכורות. הבוט לישיבה מבוטל.

09:47, אחרי פגישת ההיכרות:
היכרות: דירה בגבעתיים

סיכום
- הלקוחות רוצים לפתוח את המטבח לסלון ולהחליף ריצוף בכל הדירה
- תקציב שהוזכר: "בסביבות 250 אלף"
- רוצים להיכנס לדירה לפני פסח

החלטות
- סיור בדירה ביום ראשון ב-10:00

משימות שלי
- לשלוח הצעת מחיר לתכנון (2026-11-05)

משימות של אחרים
- יעל: לשלוח תוכנית קונסטרוקציה מהקבלן הקודם (בלי יעד)

הצעות להמשך (שום דבר לא יוצא בלי אישור)
4. email → יעל: סיכום הפגישה והסיור ביום ראשון (סוכם בדקה 41)
5. meeting → יעל ואורי: סיור בדירה (סוכם על יום ראשון ב-10:00)

לאישור: אשר <מספר> · לביטול: בטל <מספר>

שאלות פתוחות
- האם הקיר בין המטבח לסלון נושא? לבדוק בתוכנית

רוני כותבת "אשר 4" ומקבלת את נוסח המייל המלא, מתקנת מילה ושולחת. את 5 היא מאשרת אחרי שהיא בודקת ביומן שיום ראשון באמת פנוי. 52 דקות של פגישה עלו בערך 0.56 דולר, וההקלטה כבר לא קיימת אצל Recall.ai.

שימו לב: ה-AI לא המציא יעד למשימה של יעל, כי לא נאמר אחד, והשאלה על הקיר הלכה לשאלות פתוחות ולא הפכה להחלטה.


צ'קליסט לפני שמפעילים

  • החשבון ב-Recall.ai באזור האירופי, והמפתח רק בקובץ .env עם chmod 600
  • היומן משותף לקריאה בלבד עם ה-Service Account
  • פרומפט 1 רץ על שבוע של יומן, וכל הקישורים נתפסו
  • פגישות רגישות מסומנות #ללא-בוט
  • בדקתם בפגישת ניסיון שהבוט נכנס, שהתמליל בעברית, ושההקלטה נמחקה אצל Recall.ai
  • הבוט בטלגרם מקבל פקודות רק מהצ'אט שלכם
  • run_follow_up לא שולח כלום לבד, או שחיברתם אותו רק אחרי כמה שבועות של אישורים ידניים
  • יש משפט קבוע שאומרים בתחילת כל פגישה, ושורה בהזמנות
  • נקבע מתי נמחקים תמלילים מהתיקייה אצלכם

הבהרה

התוכן כאן הוא המלצות כלליות בלבד, ולא ייעוץ מקצועי, משפטי, חשבונאי או מיסויי. כל עסק וארגון שונה. האחריות לבדוק שהתוכן מתאים למצב שלכם, ולבדוק את הפקודות, הפרומפטים והקוד לפני שימוש, היא שלכם.

הקלטה של פגישה או של שיחה, ושמירה של הקלטות ותמלילים, יכולות להיות כפופות לדיני הגנת הפרטיות, לחוק האזנת סתר ולדינים נוספים, והם תלויים במצב: מי משתתף, איפה, ולאיזו מטרה. התוכן כאן לא מסביר מה החוק דורש. לפני שמפעילים בוט שמקליט פגישות עם לקוחות, עובדים או ספקים, יש להיוועץ בעורך דין. בכל מקרה, אמרו למשתתפים שמקליטים ולמה, קבלו את הסכמתם, ושמרו תמלילים רק כמה שצריך.

תמלול אוטומטי וסיכומים של כלי AI יכולים לטעות או להשמיט, במיוחד בשמות, במספרים ובתאריכים. בדקו כל הצעת המשך לפני שאתם מאשרים אותה. Recall.ai, Google Calendar, Telegram, ספקי מודלי השפה וכל שאר הכלים הם שירותים של צד שלישי. המחירים, התנאים וה-API שלהם עשויים להשתנות, והשימוש בהם כפוף לתנאים שלהם. בדקו אותם במקור.

התוכן, הפרומפטים, התבניות והקוד ניתנים כמות שהם (AS IS), בלי התחייבות שהם מתאימים למטרה מסוימת או נקיים מטעויות. Gindi Innovation ואביעד גינדי לא יישאו באחריות לכל נזק, ישיר או עקיף, שייגרם משימוש בהם או מהסתמכות עליהם, ובכלל זה מתוצרים של כלי AI. השימוש כפוף לתקנון האתר.


ומה הלאה

הערכה הזאת מסיימת את הסדרה: מהקלטה שמתמללים על המחשב, דרך תמליל שהופך למשימות, ועד בוט שעושה את כל זה לבד. אפשר לחבר את ההצעות המאושרות למערכות שאתם כבר עובדים איתן: מייל, יומן, CRM או לוח משימות, כך שאישור בטלגרם יהיה הפעולה היחידה שנשארת לכם.

חזרה לתחילת הסדרה: שלב 1, תמלול בחינם על המחשב

מה הלאה

פלייבוקים קשורים

שיחה של 30 דקות על תהליך אחד

מביאים תהליך אחד שגוזל לכם זמן. בסוף השיחה יודעים אם שווה לבנות לו פתרון, בערך כמה זה יעלה ואיך נמדוד.

לתיאום שיחה

מעדיפים לכתוב? שלחו לי הודעה בוואטסאפ