Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 4.8 1de8d054a8 fix: remove stale gateway task on channel bot restart (#277)
_start_channel_bot registers the task in gateway _channel_tasks, but
_stop_channel_bot only cleaned up _channel_bots. For the list-based
gateway registry this left the old cancelled task appended on every
restart, accumulating orphaned task references. Mirror start-time
registration by removing the task from _channel_tasks (dict pop / list
remove) on stop. Adds regression tests for both dict and list forms.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-14 10:21:01 +00:00
MervinPraisonandClaude Opus 4.8 a739ba8a6c fix: make _start_channel_bot idempotent to prevent duplicate bots (fixes #277)
Add a guard at the top of ChannelsFeature._start_channel_bot that stops any
bot already running for the same channel_id before creating a new one. This
prevents duplicate bots (with duplicate event handlers) when a channel is
toggled off/on, re-added, or when autostart races with a manual start.

Also make _stop_channel_bot await the cancelled task so the previous bot is
fully torn down before a restart proceeds, eliminating zombie asyncio tasks.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-14 09:47:54 +00:00
Mervin Praison 597280a6c6 Merge pull request #265 from Dhivya-Bharathy/feat/meeting-agent-phase-2-recall
feat(meeting-agent): Phase 2 Recall.ai live bots (Part A)
2026-09-08 16:39:01 +01:00
MervinPraisonandClaude Opus 4.8 fe954cbd4a fix(meeting-agent): drop leftover hardcoded Recall workspace UUID from part_b test
The personal contributor workspace UUID (33a035c7-...) was still hardcoded
in the test_meeting_agent_part_b.py env fixture, even though the same value
was cleaned to a neutral placeholder everywhere else (config default,
.env.example, smoke_recall.py, and test_meeting_agent_recall.py). Aligned it
with the sibling test's 'test-workspace' placeholder. No assertion depends on
the value, so behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-08 09:43:30 +00:00
MervinPraisonandClaude Opus 4.8 ab9ef2544d fix(meeting-agent): point Part B tests at renamed meeting_integrations dir
The Part A branch renamed integrations/recall -> meeting_integrations/recall
to avoid a tests/unit/integrations module-name collision, but the Part B test
loaders (test_meeting_agent_part_b.py) still resolved the old integrations/recall
paths, raising FileNotFoundError on CI (py3.10/3.11/3.12). Repoint the 9 _load()
paths at meeting_integrations/recall to match the source layout and the already-
updated test_meeting_agent_recall.py.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-08 09:39:03 +00:00
MervinPraisonandCursor e15b3c0718 merge: resolve conflicts with main (Part B + voice-agent merged)
Reconcile meeting-agent Phase A with main after #266/#267: keep
meeting_integrations/ package rename, merge Part B live-transcript and
Calendar V2 routes, and preserve Part A fixes (atomic webhook idempotency,
background task refs, workspace ID placeholders, py3.10 compiler compat).
Update part_b tests to meeting_integrations paths.

Co-authored-by: Cursor <[email protected]>
2026-09-08 10:34:47 +01:00
MervinPraisonandClaude Opus 4.8 12b42e88be fix(meeting-agent): make Recall webhook idempotency check atomic
Replace the non-atomic SELECT-then-INSERT in mark_event_processed with an
atomic INSERT OR IGNORE + rowcount check. Under concurrent duplicate webhook
deliveries (Svix retries/fan-out) both callers could pass the SELECT and the
second INSERT would raise an unhandled IntegrityError on the primary key,
silently dropping that event's processing after a 200 was returned.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:29:26 +00:00
MervinPraisonandClaude Opus 4.8 5e80f11535 fix(meeting-agent): drop hardcoded personal Recall workspace ID from example
The RECALL_WORKSPACE_ID default was a real contributor workspace UUID
committed across the example (config default, README, .env.example, smoke
script, test). It is non-functional — the Recall API key already scopes
requests to a workspace and the value is only echoed back by the
config-status endpoint. Default it to an empty string and replace the
committed UUID with neutral placeholders.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:25:58 +00:00
MervinPraisonandClaude Opus 4.8 84ae1fd0ac fix(meeting-agent): retain webhook background task refs to prevent GC
asyncio only holds weak references to tasks created via create_task, so the
fire-and-forget Recall webhook processing task could be garbage-collected
mid-execution, silently dropping transcript/status updates. Keep strong refs
in a module-level set and discard on completion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:21:44 +00:00
MervinPraisonandClaude Opus 4.8 b59af41d88 fix(compiler): use timezone.utc for Python 3.10 compatibility
datetime.UTC was added in Python 3.11, so importing it broke the
python (3.10) CI job with ImportError in _format_lastmod. Switch to
timezone.utc which is equivalent and available on all supported
Python versions (3.10+).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:17:41 +00:00
MervinPraisonandClaude Opus 4.8 8e5c4d40e2 fix(meeting-agent): resolve CI failures on Phase 2 Recall PR
- Rename example package integrations/ -> meeting_integrations/ to avoid a
  top-level module-name collision with tests/unit/integrations (imported as
  top-level 'integrations' because tests/unit has no __init__.py). The
  collision made 'meeting_integrations.recall' unresolvable once the test
  session imported the tests integrations package first, causing 8 errors +
  3 failures. Updated all absolute imports and test path references.
- Guard praisonai_tools-dependent tests with pytest.importorskip: the meeting
  tools live in the external PraisonAI-Tools package and are not installed in
  this repo's CI, so those tests skip cleanly instead of erroring.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:15:01 +00:00
24 changed files with 181 additions and 87 deletions

No files matched your search

+3 -1
View File
@@ -13,7 +13,9 @@ MEETING_AGENT_PORT=8000
# ── Recall.ai (EU workspace: Praison) ───────────────────────────────────────
# Region must match dashboard: eu-central-1 (Frankfurt).
RECALL_REGION=eu-central-1
RECALL_WORKSPACE_ID=33a035c7-03c1-4ec9-b752-18cdfbbefc42
# Optional: your Recall workspace ID (shown in the config-status API only; the
# API key already scopes requests to a workspace).
RECALL_WORKSPACE_ID=
# REST API key from Recall dashboard (Developers → API Keys). Server-side only.
RECALL_API_KEY=
+3 -2
View File
@@ -17,9 +17,10 @@ Meeting Bots for live Zoom / Meet / Teams capture.
## Recall.ai workspace
This integration targets the **Praison** workspace in **EU (Frankfurt)**:
This integration targets your Recall workspace in **EU (Frankfurt)** by default:
- Workspace ID: `33a035c7-03c1-4ec9-b752-18cdfbbefc42`
- Workspace ID: set `RECALL_WORKSPACE_ID` to your own (optional — the API key
already scopes requests to a workspace)
- API region: `eu-central-1`
Follow the [Recall onboarding guide](https://docs.recall.ai/docs/agent-quickstarts) via Recall MCP (`recall://guides/onboarding`) for credential and webhook setup.
+5 -5
View File
@@ -102,7 +102,7 @@ _agent: Any | None = None
def _recall_schedule_tool():
from praisonai_tools.tools.decorator import tool
from integrations.recall.service import schedule_recall_bot as _schedule
from meeting_integrations.recall.service import schedule_recall_bot as _schedule
@tool
def schedule_recall_bot(meeting_url: str, title: str = "") -> dict:
@@ -115,7 +115,7 @@ def _recall_schedule_tool():
def _upcoming_meetings_tool():
from praisonai_tools.tools.decorator import tool
from integrations.recall.calendar_service import list_upcoming_calendar_meetings
from meeting_integrations.recall.calendar_service import list_upcoming_calendar_meetings
@tool
def get_upcoming_meetings(hours: int = 24) -> list[dict]:
@@ -194,8 +194,8 @@ def _sync_stale_recall_status(record: dict[str, Any]) -> dict[str, Any]:
if not os.getenv("RECALL_API_KEY"):
return record
try:
from integrations.recall.client import RecallClient
from integrations.recall.config import load_recall_settings
from meeting_integrations.recall.client import RecallClient
from meeting_integrations.recall.config import load_recall_settings
from pipeline import merge_meeting_metadata
bot = RecallClient(load_recall_settings()).get_bot(str(bot_id))
@@ -464,7 +464,7 @@ async def calendar_page():
status_note = "Set RECALL_API_KEY to load calendar status."
if os.getenv("RECALL_API_KEY"):
try:
from integrations.recall.calendar_service import list_upcoming_calendar_meetings
from meeting_integrations.recall.calendar_service import list_upcoming_calendar_meetings
upcoming = list_upcoming_calendar_meetings(hours=48)
status_note = f"{len(upcoming)} upcoming event(s) with video links (48h window)."
@@ -1,13 +0,0 @@
"""Recall.ai Meeting Bot integration for the meeting-agent example."""
from integrations.recall.client import RecallClient
from integrations.recall.config import RecallSettings, load_recall_settings
from integrations.recall.verify import VerificationError, verify_request_from_recall
__all__ = [
"RecallClient",
"RecallSettings",
"VerificationError",
"load_recall_settings",
"verify_request_from_recall",
]
@@ -0,0 +1,13 @@
"""Recall.ai Meeting Bot integration for the meeting-agent example."""
from meeting_integrations.recall.client import RecallClient
from meeting_integrations.recall.config import RecallSettings, load_recall_settings
from meeting_integrations.recall.verify import VerificationError, verify_request_from_recall
__all__ = [
"RecallClient",
"RecallSettings",
"VerificationError",
"load_recall_settings",
"verify_request_from_recall",
]
@@ -6,9 +6,9 @@ import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from integrations.recall.client import RecallAPIError, RecallClient
from integrations.recall.config import RecallSettings
from integrations.recall.store import RecallStore
from meeting_integrations.recall.client import RecallAPIError, RecallClient
from meeting_integrations.recall.config import RecallSettings
from meeting_integrations.recall.store import RecallStore
logger = logging.getLogger(__name__)
@@ -64,7 +64,7 @@ def list_upcoming_calendar_meetings(
client: RecallClient | None = None,
) -> list[dict[str, Any]]:
"""List upcoming Recall calendar events with video links (for UI + agent tool)."""
from integrations.recall.config import load_recall_settings
from meeting_integrations.recall.config import load_recall_settings
settings = settings or load_recall_settings()
client = client or RecallClient(settings)
@@ -10,7 +10,7 @@ from typing import Any
import httpx
from integrations.recall.config import RecallSettings
from meeting_integrations.recall.config import RecallSettings
logger = logging.getLogger(__name__)
@@ -93,9 +93,7 @@ def load_recall_settings(*, require_public_url: bool = False) -> RecallSettings:
api_key=_require("RECALL_API_KEY"),
webhook_verification_secret=_require("RECALL_WEBHOOK_VERIFICATION_SECRET"),
public_api_base_url=public_url,
workspace_id=os.getenv(
"RECALL_WORKSPACE_ID", "33a035c7-03c1-4ec9-b752-18cdfbbefc42"
).strip(),
workspace_id=os.getenv("RECALL_WORKSPACE_ID", "").strip(),
bot_name=os.getenv("RECALL_BOT_NAME", "Praison Meeting Agent").strip()
or "Praison Meeting Agent",
calendar_auto_record=auto_record not in {"0", "false", "no", "off"},
@@ -5,10 +5,10 @@ from __future__ import annotations
import logging
from typing import Any
from integrations.recall.client import RecallAPIError, RecallClient
from integrations.recall.config import RecallSettings
from integrations.recall.store import RecallStore, webhook_event_key
from integrations.recall.transcript import transcript_data_to_line, transcript_download_to_text
from meeting_integrations.recall.client import RecallAPIError, RecallClient
from meeting_integrations.recall.config import RecallSettings
from meeting_integrations.recall.store import RecallStore, webhook_event_key
from meeting_integrations.recall.transcript import transcript_data_to_line, transcript_download_to_text
logger = logging.getLogger(__name__)
@@ -22,7 +22,7 @@ def _speaker_prefix(line: str) -> str:
def _upsert_live_transcript(meeting_id: str, line: str, *, partial: bool = False) -> str:
"""Merge one live caption line; partial updates replace the last same-speaker line."""
from praisonai_tools.tools.meeting_tools import get_meeting
from integrations.recall.live import publish_live_chunk
from meeting_integrations.recall.live import publish_live_chunk
from pipeline import _metadata_lock
# Hold the merge lock across the read too: streaming transcript events are
@@ -120,7 +120,7 @@ def process_recall_webhook(
if event_type == "bot.in_call_recording" and meeting_id:
_merge_metadata(meeting_id, {"live_status": "live", "status": "live", "error": ""})
from integrations.recall.live import set_live_status
from meeting_integrations.recall.live import set_live_status
set_live_status(meeting_id, "live")
return
@@ -139,7 +139,7 @@ def process_recall_webhook(
if event_type == "bot.call_ended" and meeting_id:
_merge_metadata(meeting_id, {"live_status": "ended"})
from integrations.recall.live import set_live_status
from meeting_integrations.recall.live import set_live_status
set_live_status(meeting_id, "ended")
return
@@ -6,8 +6,8 @@ import logging
import re
from typing import Any
from integrations.recall.client import RecallAPIError, RecallClient
from integrations.recall.config import RecallConfigError, RecallSettings, load_recall_settings
from meeting_integrations.recall.client import RecallAPIError, RecallClient
from meeting_integrations.recall.config import RecallConfigError, RecallSettings, load_recall_settings
logger = logging.getLogger(__name__)
@@ -9,7 +9,7 @@ from contextlib import closing
from pathlib import Path
from typing import Any
from integrations.recall.client import RecallClient
from meeting_integrations.recall.client import RecallClient
def _state_db_path() -> str:
@@ -100,42 +100,42 @@ class RecallStore:
@staticmethod
def extract_bot_id(payload: dict[str, Any]) -> str | None:
from integrations.recall.client import RecallClient
from meeting_integrations.recall.client import RecallClient
return RecallClient.extract_bot_id(payload)
@staticmethod
def extract_recording_id(payload: dict[str, Any]) -> str | None:
from integrations.recall.client import RecallClient
from meeting_integrations.recall.client import RecallClient
return RecallClient.extract_recording_id(payload)
@staticmethod
def extract_transcript_id(payload: dict[str, Any]) -> str | None:
from integrations.recall.client import RecallClient
from meeting_integrations.recall.client import RecallClient
return RecallClient.extract_transcript_id(payload)
@staticmethod
def mark_event_processed(event_key: str, event_type: str) -> bool:
"""Return False if this event was already processed."""
"""Claim an event for processing; return False if already processed.
Uses an atomic ``INSERT OR IGNORE`` so concurrent duplicate webhook
deliveries (Svix retries/fan-out) cannot both pass the check — the
second insert is ignored and reports zero changed rows.
"""
from datetime import datetime, timezone
with closing(_connect()) as conn, conn:
row = conn.execute(
"SELECT 1 FROM recall_webhook_events WHERE event_key = ?",
(event_key,),
).fetchone()
if row:
return False
conn.execute(
cursor = conn.execute(
"""
INSERT INTO recall_webhook_events (event_key, event_type, processed_at)
INSERT OR IGNORE INTO recall_webhook_events
(event_key, event_type, processed_at)
VALUES (?, ?, ?)
""",
(event_key, event_type, datetime.now(timezone.utc).isoformat()),
)
return True
return cursor.rowcount > 0
@staticmethod
def save_calendar_setup(setup_id: str, platform: str, state: dict[str, Any]) -> None:
@@ -11,18 +11,18 @@ from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
from integrations.recall.calendar import forward_calendar_callback
from integrations.recall.calendar_service import (
from meeting_integrations.recall.calendar import forward_calendar_callback
from meeting_integrations.recall.calendar_service import (
list_upcoming_calendar_meetings,
process_calendar_webhook,
sync_calendar_events,
)
from integrations.recall.config import RecallConfigError, load_recall_settings
from integrations.recall.live import snapshot_for_api, wait_for_update
from integrations.recall.processor import process_recall_webhook
from integrations.recall.service import cancel_recall_bot, schedule_recall_bot
from integrations.recall.store import RecallStore
from integrations.recall.verify import VerificationError, verify_request_from_recall
from meeting_integrations.recall.config import RecallConfigError, load_recall_settings
from meeting_integrations.recall.live import snapshot_for_api, wait_for_update
from meeting_integrations.recall.processor import process_recall_webhook
from meeting_integrations.recall.service import cancel_recall_bot, schedule_recall_bot
from meeting_integrations.recall.store import RecallStore
from meeting_integrations.recall.verify import VerificationError, verify_request_from_recall
logger = logging.getLogger(__name__)
@@ -208,7 +208,7 @@ async def api_recall_calendar_status(_request: Request) -> JSONResponse:
public_url = os.getenv("PUBLIC_API_BASE_URL", "").strip()
calendars: list[dict[str, Any]] = []
try:
from integrations.recall.client import RecallClient
from meeting_integrations.recall.client import RecallClient
settings = load_recall_settings()
calendars = RecallClient(settings).list_calendars(status="connected")
@@ -45,9 +45,9 @@ def _duration_seconds(started_at: str | None, completed_at: str | None) -> int |
def recover(meeting_id: str, bot_id: str | None = None) -> dict:
from integrations.recall.client import RecallClient
from integrations.recall.config import load_recall_settings
from integrations.recall.transcript import transcript_download_to_text
from meeting_integrations.recall.client import RecallClient
from meeting_integrations.recall.config import load_recall_settings
from meeting_integrations.recall.transcript import transcript_download_to_text
from pipeline import merge_meeting_metadata, run_post_transcript_pipeline
from praisonai_tools.tools.meeting_tools import get_meeting
@@ -39,12 +39,12 @@ def run_smoke() -> None:
os.environ.setdefault("RECALL_REGION", "eu-central-1")
os.environ.setdefault("RECALL_API_KEY", "smoke-test-key")
os.environ.setdefault("RECALL_WEBHOOK_VERIFICATION_SECRET", "whsec_" + base64.b64encode(b"smoke-secret-key-32-bytes-long!!").decode())
os.environ.setdefault("RECALL_WORKSPACE_ID", "33a035c7-03c1-4ec9-b752-18cdfbbefc42")
os.environ.setdefault("RECALL_WORKSPACE_ID", "smoke-workspace")
os.environ.setdefault("PUBLIC_API_BASE_URL", "https://smoke.example.test")
import app as meeting_app # noqa: WPS433
from integrations.recall.config import load_recall_settings
from integrations.recall.verify import verify_request_from_recall
from meeting_integrations.recall.config import load_recall_settings
from meeting_integrations.recall.verify import verify_request_from_recall
settings = load_recall_settings()
assert settings.api_key == "smoke-test-key"
+27 -1
View File
@@ -315,6 +315,13 @@ class ChannelsFeature(BaseFeatureProtocol):
Returns None on success, or an error string on failure.
"""
# Idempotent start (#277): stop any bot already running for this channel
# before creating a new one. Without this, a toggle off/on, a re-add, or
# an autostart+manual race leaves a duplicate bot with duplicate event
# handlers attached while _live_bots only tracks the latest task.
if channel_id in _live_bots:
await self._stop_channel_bot(channel_id)
platform = entry["platform"]
config = entry.get("config", {})
fallback_errors: List[str] = []
@@ -905,9 +912,15 @@ class ChannelsFeature(BaseFeatureProtocol):
try:
if hasattr(bot, "stop"):
await bot.stop()
# Cancel the task if still running
# Cancel the task and await it so the old bot is fully torn down
# before this returns — otherwise a restart (#277) can spawn a new
# bot while the previous task is still cancelling (a zombie).
if info and info.get("task") and not info["task"].done():
info["task"].cancel()
try:
await info["task"]
except asyncio.CancelledError:
pass
logger.info(f"Stopped bot for channel '{channel_id}'")
except Exception as e:
logger.error(f"Error stopping bot for '{channel_id}': {e}")
@@ -918,6 +931,19 @@ class ChannelsFeature(BaseFeatureProtocol):
gw = self._get_gateway()
if gw is not None:
getattr(gw, "_channel_bots", {}).pop(channel_id, None)
# Mirror start-time registration: drop the task from the gateway
# too, so a restart (#277) leaves no stale/cancelled task behind.
# The dict form is overwritten on restart, but the list form would
# otherwise accumulate a cancelled task on every restart.
channel_tasks = getattr(gw, "_channel_tasks", None)
task = info.get("task") if info else None
if isinstance(channel_tasks, dict):
channel_tasks.pop(channel_id, None)
elif isinstance(channel_tasks, list) and task is not None:
try:
channel_tasks.remove(task)
except ValueError:
pass
# Update local state
ch = _channels.get(channel_id)
+68
View File
@@ -144,6 +144,74 @@ async def test_auto_start_skips_disabled_channels():
assert mock_start.call_count == 0
@pytest.mark.asyncio
async def test_start_channel_bot_is_idempotent_stops_existing_first():
"""Regression for #277: starting a bot for a channel that already has a
live bot must stop the existing one first so no duplicate bot/task lingers.
"""
feature = ChannelsFeature()
gw = _GatewayDictTasks()
entry = {"id": "ch6", "platform": "discord", "config": {"bot_token": "x" * 30}}
with patch.object(feature, "_get_gateway", return_value=gw), patch.object(
feature, "_create_bot_direct", side_effect=lambda *a, **k: _FakeBot()
), patch.object(feature, "_attach_chat_bridge"):
await feature._start_channel_bot("ch6", entry)
first = channels_mod._live_bots["ch6"]
await feature._start_channel_bot("ch6", entry)
second = channels_mod._live_bots["ch6"]
# A new bot/task replaced the first, and only one entry is tracked.
assert second is not first
assert first["bot"] is not second["bot"]
assert list(channels_mod._live_bots.keys()).count("ch6") == 1
# The first task was cancelled by the pre-start _stop_channel_bot guard.
assert first["task"].cancelled() or first["task"].done()
# Gateway has exactly one (fresh) bot reference — no stale entry.
assert gw._channel_bots["ch6"] is second["bot"]
# ...and exactly one (fresh) task reference in the dict registry.
assert gw._channel_tasks["ch6"] is second["task"]
@pytest.mark.asyncio
async def test_restart_leaves_no_stale_task_in_list_gateway():
"""Regression for #277: with a list-based gateway _channel_tasks, a restart
must not accumulate the old cancelled task alongside the fresh one.
"""
feature = ChannelsFeature()
gw = _GatewayListTasks()
entry = {"id": "ch8", "platform": "discord", "config": {"bot_token": "x" * 30}}
with patch.object(feature, "_get_gateway", return_value=gw), patch.object(
feature, "_create_bot_direct", side_effect=lambda *a, **k: _FakeBot()
), patch.object(feature, "_attach_chat_bridge"):
await feature._start_channel_bot("ch8", entry)
await feature._start_channel_bot("ch8", entry)
current = channels_mod._live_bots["ch8"]
# Only the fresh task remains in the list — the cancelled one was removed.
assert gw._channel_tasks == [current["task"]]
@pytest.mark.asyncio
async def test_start_channel_bot_no_existing_does_not_stop():
"""Guard must be a no-op on first start (nothing in _live_bots)."""
feature = ChannelsFeature()
gw = _GatewayDictTasks()
entry = {"id": "ch7", "platform": "discord", "config": {"bot_token": "x" * 30}}
with patch.object(feature, "_get_gateway", return_value=gw), patch.object(
feature, "_create_bot_direct", return_value=_FakeBot()
), patch.object(feature, "_attach_chat_bridge"), patch.object(
feature, "_stop_channel_bot"
) as mock_stop:
error = await feature._start_channel_bot("ch7", entry)
assert error is None
mock_stop.assert_not_called()
@pytest.mark.asyncio
async def test_auto_start_failure_sets_not_running_and_error():
feature = ChannelsFeature()
+11 -11
View File
@@ -27,7 +27,7 @@ def part_b_env(monkeypatch, tmp_path):
monkeypatch.setenv("RECALL_REGION", "eu-central-1")
monkeypatch.setenv("RECALL_API_KEY", "test-recall-key")
monkeypatch.setenv("RECALL_WEBHOOK_VERIFICATION_SECRET", "whsec_test")
monkeypatch.setenv("RECALL_WORKSPACE_ID", "33a035c7-03c1-4ec9-b752-18cdfbbefc42")
monkeypatch.setenv("RECALL_WORKSPACE_ID", "test-workspace")
monkeypatch.setenv("PUBLIC_API_BASE_URL", "https://dev.example.test")
monkeypatch.setenv("PRAISONAI_MEETINGS_DIR", str(tmp_path))
monkeypatch.setenv("PRAISONAI_MEETINGS_DB", str(tmp_path / "meetings.db"))
@@ -35,7 +35,7 @@ def part_b_env(monkeypatch, tmp_path):
def test_transcript_data_to_line():
tx = _load("part_b_transcript", _ROOT / "integrations" / "recall" / "transcript.py")
tx = _load("part_b_transcript", _ROOT / "meeting_integrations" / "recall" / "transcript.py")
payload = {
"data": {
"data": {
@@ -48,7 +48,7 @@ def test_transcript_data_to_line():
def test_live_hub_publish_and_snapshot():
live = _load("part_b_live", _ROOT / "integrations" / "recall" / "live.py")
live = _load("part_b_live", _ROOT / "meeting_integrations" / "recall" / "live.py")
live.publish_live_chunk("m-live", line="Alice: Hi", full_text="Alice: Hi", live_status="live")
snap = live.snapshot_for_api("m-live", {"metadata": {}})
assert snap["transcript"] == "Alice: Hi"
@@ -56,8 +56,8 @@ def test_live_hub_publish_and_snapshot():
def test_create_bot_includes_realtime_config():
client_mod = _load("part_b_client", _ROOT / "integrations" / "recall" / "client.py")
config_mod = _load("part_b_config", _ROOT / "integrations" / "recall" / "config.py")
client_mod = _load("part_b_client", _ROOT / "meeting_integrations" / "recall" / "client.py")
config_mod = _load("part_b_config", _ROOT / "meeting_integrations" / "recall" / "config.py")
settings = config_mod.load_recall_settings()
client = client_mod.RecallClient(settings)
client._request = MagicMock(return_value={"id": "bot-1"}) # noqa: SLF001
@@ -76,7 +76,7 @@ def test_create_bot_includes_realtime_config():
def test_calendar_eligibility():
cal = _load("part_b_calendar", _ROOT / "integrations" / "recall" / "calendar_service.py")
cal = _load("part_b_calendar", _ROOT / "meeting_integrations" / "recall" / "calendar_service.py")
assert cal.is_eligible_for_auto_record(
{
"is_deleted": False,
@@ -88,7 +88,7 @@ def test_calendar_eligibility():
def test_transcript_webhook_keys_are_unique_per_utterance():
store = _load("part_b_store", _ROOT / "integrations" / "recall" / "store.py")
store = _load("part_b_store", _ROOT / "meeting_integrations" / "recall" / "store.py")
bot = "bot-1"
p1 = {
"data": {
@@ -109,8 +109,8 @@ def test_transcript_webhook_keys_are_unique_per_utterance():
def test_upsert_live_transcript_replaces_partial():
pytest.importorskip("praisonai_tools") # external PraisonAI-Tools dep, optional in CI
processor = _load("part_b_processor2", _ROOT / "integrations" / "recall" / "processor.py")
with patch("integrations.recall.live.publish_live_chunk") as publish:
processor = _load("part_b_processor2", _ROOT / "meeting_integrations" / "recall" / "processor.py")
with patch("meeting_integrations.recall.live.publish_live_chunk") as publish:
with patch("praisonai_tools.tools.meeting_tools.get_meeting") as get_m:
get_m.__wrapped__ = MagicMock(
return_value={"metadata": {"transcript": "Alice: hel"}}
@@ -121,8 +121,8 @@ def test_upsert_live_transcript_replaces_partial():
def test_process_transcript_data_webhook():
processor = _load("part_b_processor", _ROOT / "integrations" / "recall" / "processor.py")
config_mod = _load("part_b_config2", _ROOT / "integrations" / "recall" / "config.py")
processor = _load("part_b_processor", _ROOT / "meeting_integrations" / "recall" / "processor.py")
config_mod = _load("part_b_config2", _ROOT / "meeting_integrations" / "recall" / "config.py")
settings = config_mod.load_recall_settings()
with patch.object(processor, "_append_live_transcript") as append:
+1 -2
View File
@@ -9,8 +9,7 @@ from unittest.mock import patch
import pytest
# The ingest pipeline calls the external PraisonAI-Tools ``meeting_tools`` at
# runtime; it is not a PraisonAIUI CI dependency, so skip when unavailable.
# Meeting tools live in the external PraisonAI-Tools package; skip when unavailable in CI.
pytest.importorskip("praisonai_tools")
_PIPELINE = (
+6 -6
View File
@@ -32,7 +32,7 @@ def recall_env(monkeypatch, tmp_path):
monkeypatch.setenv("RECALL_REGION", "eu-central-1")
monkeypatch.setenv("RECALL_API_KEY", "test-recall-key")
monkeypatch.setenv("RECALL_WEBHOOK_VERIFICATION_SECRET", _SECRET)
monkeypatch.setenv("RECALL_WORKSPACE_ID", "33a035c7-03c1-4ec9-b752-18cdfbbefc42")
monkeypatch.setenv("RECALL_WORKSPACE_ID", "test-workspace")
monkeypatch.setenv("PUBLIC_API_BASE_URL", "https://dev.example.test")
monkeypatch.setenv("PRAISONAI_MEETINGS_DIR", str(tmp_path))
monkeypatch.setenv("PRAISONAI_MEETINGS_DB", str(tmp_path / "meetings.db"))
@@ -40,7 +40,7 @@ def recall_env(monkeypatch, tmp_path):
def test_verify_request_accepts_valid_signature():
verify_mod = _load("recall_verify", _ROOT / "integrations" / "recall" / "verify.py")
verify_mod = _load("recall_verify", _ROOT / "meeting_integrations" / "recall" / "verify.py")
import hmac
import hashlib
@@ -63,7 +63,7 @@ def test_verify_request_accepts_valid_signature():
def test_verify_request_rejects_bad_signature():
verify_mod = _load("recall_verify", _ROOT / "integrations" / "recall" / "verify.py")
verify_mod = _load("recall_verify", _ROOT / "meeting_integrations" / "recall" / "verify.py")
with pytest.raises(verify_mod.VerificationError):
verify_mod.verify_request_from_recall(
secret=_SECRET,
@@ -78,7 +78,7 @@ def test_verify_request_rejects_bad_signature():
def test_schedule_bot_persists_intent_before_api():
pytest.importorskip("praisonai_tools") # external PraisonAI-Tools dep, optional in CI
service = _load("recall_service", _ROOT / "integrations" / "recall" / "service.py")
service = _load("recall_service", _ROOT / "meeting_integrations" / "recall" / "service.py")
client = MagicMock()
client.create_bot.return_value = {"id": "bot-abc"}
@@ -104,7 +104,7 @@ def test_webhook_rejects_invalid_signature():
def test_webhook_accepts_valid_signature_and_queues():
example = _load("meeting_agent_recall_app2", _ROOT / "app.py")
verify_mod = _load("recall_verify2", _ROOT / "integrations" / "recall" / "verify.py")
verify_mod = _load("recall_verify2", _ROOT / "meeting_integrations" / "recall" / "verify.py")
import hmac
import hashlib
@@ -136,7 +136,7 @@ def test_webhook_accepts_valid_signature_and_queues():
def test_transcript_download_to_text():
tx = _load("recall_transcript", _ROOT / "integrations" / "recall" / "transcript.py")
tx = _load("recall_transcript", _ROOT / "meeting_integrations" / "recall" / "transcript.py")
data = [
{"speaker": "Alice", "words": [{"text": "Hello"}, {"text": "team"}]},
{"speaker": "Bob", "text": "Hi there"},