feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
"""BlueBubbles iMessage platform adapter.
|
|
|
|
|
|
|
|
|
|
Uses the local BlueBubbles macOS server for outbound REST sends and inbound
|
|
|
|
|
webhooks. Supports text messaging, media attachments (images, voice, video,
|
|
|
|
|
documents), tapback reactions, typing indicators, and read receipts.
|
|
|
|
|
|
|
|
|
|
Architecture based on PR #5869 (benjaminsehl) with inbound attachment
|
|
|
|
|
downloading from PR #4588 (YuhangLin).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import uuid
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
from urllib.parse import quote
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
|
|
|
from gateway.platforms.base import (
|
|
|
|
|
BasePlatformAdapter,
|
|
|
|
|
MessageEvent,
|
|
|
|
|
MessageType,
|
|
|
|
|
SendResult,
|
|
|
|
|
cache_image_from_bytes,
|
|
|
|
|
cache_audio_from_bytes,
|
|
|
|
|
cache_document_from_bytes,
|
|
|
|
|
)
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
from gateway.platforms.helpers import strip_markdown
|
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Constants
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
DEFAULT_WEBHOOK_HOST = "127.0.0.1"
|
|
|
|
|
DEFAULT_WEBHOOK_PORT = 8645
|
|
|
|
|
DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"
|
|
|
|
|
MAX_TEXT_LENGTH = 4000
|
|
|
|
|
|
|
|
|
|
# Tapback reaction codes (BlueBubbles associatedMessageType values)
|
|
|
|
|
_TAPBACK_ADDED = {
|
|
|
|
|
2000: "love", 2001: "like", 2002: "dislike",
|
|
|
|
|
2003: "laugh", 2004: "emphasize", 2005: "question",
|
|
|
|
|
}
|
|
|
|
|
_TAPBACK_REMOVED = {
|
|
|
|
|
3000: "love", 3001: "like", 3002: "dislike",
|
|
|
|
|
3003: "laugh", 3004: "emphasize", 3005: "question",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Webhook event types that carry user messages
|
|
|
|
|
_MESSAGE_EVENTS = {"new-message", "message", "updated-message"}
|
|
|
|
|
|
|
|
|
|
# Log redaction patterns
|
|
|
|
|
_PHONE_RE = re.compile(r"\+?\d{7,15}")
|
|
|
|
|
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _redact(text: str) -> str:
|
|
|
|
|
"""Redact phone numbers and emails from log output."""
|
|
|
|
|
text = _PHONE_RE.sub("[REDACTED]", text)
|
|
|
|
|
text = _EMAIL_RE.sub("[REDACTED]", text)
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def check_bluebubbles_requirements() -> bool:
|
|
|
|
|
try:
|
|
|
|
|
import aiohttp # noqa: F401
|
refactor: remove remaining redundant local imports (comprehensive sweep)
Full AST-based scan of all .py files to find every case where a module
or name is imported locally inside a function body but is already
available at module level. This is the second pass — the first commit
handled the known cases from the lint report; this one catches
everything else.
Files changed (19):
cli.py — 16 removals: time as _time/_t/_tmod (×10),
re / re as _re (×2), os as _os, sys,
partial os from combo import,
from model_tools import get_tool_definitions
gateway/run.py — 8 removals: MessageEvent as _ME /
MessageType as _MT (×3), os as _os2,
MessageEvent+MessageType (×2), Platform,
BasePlatformAdapter as _BaseAdapter
run_agent.py — 6 removals: get_hermes_home as _ghh,
partial (contextlib, os as _os),
cleanup_vm, cleanup_browser,
set_interrupt as _sif (×2),
partial get_toolset_for_tool
hermes_cli/main.py — 4 removals: get_hermes_home, time as _time,
logging as _log, shutil
hermes_cli/config.py — 1 removal: get_hermes_home as _ghome
hermes_cli/runtime_provider.py
— 1 removal: load_config as _load_bedrock_config
hermes_cli/setup.py — 2 removals: importlib.util (×2)
hermes_cli/nous_subscription.py
— 1 removal: from hermes_cli.config import load_config
hermes_cli/tools_config.py
— 1 removal: from hermes_cli.config import load_config, save_config
cron/scheduler.py — 3 removals: concurrent.futures, json as _json,
from hermes_cli.config import load_config
batch_runner.py — 1 removal: list_distributions as get_all_dists
(kept print_distribution_info, not at top level)
tools/send_message_tool.py
— 2 removals: import os (×2)
tools/skills_tool.py — 1 removal: logging as _logging
tools/browser_camofox.py
— 1 removal: from hermes_cli.config import load_config
tools/image_generation_tool.py
— 1 removal: import fal_client
environments/tool_context.py
— 1 removal: concurrent.futures
gateway/platforms/bluebubbles.py
— 1 removal: httpx as _httpx
gateway/platforms/whatsapp.py
— 1 removal: import asyncio
tui_gateway/server.py — 2 removals: from datetime import datetime,
import time
All alias references (_time, _t, _tmod, _re, _os, _os2, _json, _ghh,
_ghome, _sif, _ME, _MT, _BaseAdapter, _load_bedrock_config, _httpx,
_logging, _log, get_all_dists) updated to use the top-level names.
2026-04-21 12:46:31 +05:30
|
|
|
import httpx # noqa: F401
|
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
except ImportError:
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_server_url(raw: str) -> str:
|
|
|
|
|
value = (raw or "").strip()
|
|
|
|
|
if not value:
|
|
|
|
|
return ""
|
|
|
|
|
if not re.match(r"^https?://", value, flags=re.I):
|
|
|
|
|
value = f"http://{value}"
|
|
|
|
|
return value.rstrip("/")
|
|
|
|
|
|
|
|
|
|
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
|
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Adapter
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class BlueBubblesAdapter(BasePlatformAdapter):
|
|
|
|
|
platform = Platform.BLUEBUBBLES
|
|
|
|
|
MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH
|
|
|
|
|
|
|
|
|
|
def __init__(self, config: PlatformConfig):
|
|
|
|
|
super().__init__(config, Platform.BLUEBUBBLES)
|
|
|
|
|
extra = config.extra or {}
|
|
|
|
|
self.server_url = _normalize_server_url(
|
|
|
|
|
extra.get("server_url") or os.getenv("BLUEBUBBLES_SERVER_URL", "")
|
|
|
|
|
)
|
|
|
|
|
self.password = extra.get("password") or os.getenv("BLUEBUBBLES_PASSWORD", "")
|
|
|
|
|
self.webhook_host = (
|
|
|
|
|
extra.get("webhook_host")
|
|
|
|
|
or os.getenv("BLUEBUBBLES_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST)
|
|
|
|
|
)
|
|
|
|
|
self.webhook_port = int(
|
|
|
|
|
extra.get("webhook_port")
|
|
|
|
|
or os.getenv("BLUEBUBBLES_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT))
|
|
|
|
|
)
|
|
|
|
|
self.webhook_path = (
|
|
|
|
|
extra.get("webhook_path")
|
|
|
|
|
or os.getenv("BLUEBUBBLES_WEBHOOK_PATH", DEFAULT_WEBHOOK_PATH)
|
|
|
|
|
)
|
|
|
|
|
if not str(self.webhook_path).startswith("/"):
|
|
|
|
|
self.webhook_path = f"/{self.webhook_path}"
|
|
|
|
|
self.send_read_receipts = bool(extra.get("send_read_receipts", True))
|
|
|
|
|
self.client: Optional[httpx.AsyncClient] = None
|
|
|
|
|
self._runner = None
|
|
|
|
|
self._private_api_enabled: Optional[bool] = None
|
|
|
|
|
self._helper_connected: bool = False
|
|
|
|
|
self._guid_cache: Dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# API helpers
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _api_url(self, path: str) -> str:
|
|
|
|
|
sep = "&" if "?" in path else "?"
|
|
|
|
|
return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}"
|
|
|
|
|
|
|
|
|
|
async def _api_get(self, path: str) -> Dict[str, Any]:
|
|
|
|
|
assert self.client is not None
|
|
|
|
|
res = await self.client.get(self._api_url(path))
|
|
|
|
|
res.raise_for_status()
|
|
|
|
|
return res.json()
|
|
|
|
|
|
|
|
|
|
async def _api_post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
assert self.client is not None
|
|
|
|
|
res = await self.client.post(self._api_url(path), json=payload)
|
|
|
|
|
res.raise_for_status()
|
|
|
|
|
return res.json()
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Lifecycle
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def connect(self) -> bool:
|
|
|
|
|
if not self.server_url or not self.password:
|
|
|
|
|
logger.error(
|
|
|
|
|
"[bluebubbles] BLUEBUBBLES_SERVER_URL and BLUEBUBBLES_PASSWORD are required"
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
from aiohttp import web
|
|
|
|
|
|
|
|
|
|
self.client = httpx.AsyncClient(timeout=30.0)
|
|
|
|
|
try:
|
|
|
|
|
await self._api_get("/api/v1/ping")
|
|
|
|
|
info = await self._api_get("/api/v1/server/info")
|
|
|
|
|
server_data = (info or {}).get("data", {})
|
|
|
|
|
self._private_api_enabled = bool(server_data.get("private_api"))
|
|
|
|
|
self._helper_connected = bool(server_data.get("helper_connected"))
|
|
|
|
|
logger.info(
|
|
|
|
|
"[bluebubbles] connected to %s (private_api=%s, helper=%s)",
|
|
|
|
|
self.server_url,
|
|
|
|
|
self._private_api_enabled,
|
|
|
|
|
self._helper_connected,
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error(
|
|
|
|
|
"[bluebubbles] cannot reach server at %s: %s", self.server_url, exc
|
|
|
|
|
)
|
|
|
|
|
if self.client:
|
|
|
|
|
await self.client.aclose()
|
|
|
|
|
self.client = None
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
app = web.Application()
|
|
|
|
|
app.router.add_get("/health", lambda _: web.Response(text="ok"))
|
|
|
|
|
app.router.add_post(self.webhook_path, self._handle_webhook)
|
|
|
|
|
self._runner = web.AppRunner(app)
|
|
|
|
|
await self._runner.setup()
|
|
|
|
|
site = web.TCPSite(self._runner, self.webhook_host, self.webhook_port)
|
|
|
|
|
await site.start()
|
|
|
|
|
self._mark_connected()
|
|
|
|
|
logger.info(
|
|
|
|
|
"[bluebubbles] webhook listening on http://%s:%s%s",
|
|
|
|
|
self.webhook_host,
|
|
|
|
|
self.webhook_port,
|
|
|
|
|
self.webhook_path,
|
|
|
|
|
)
|
2026-04-09 12:21:25 +00:00
|
|
|
|
|
|
|
|
# Register webhook with BlueBubbles server
|
|
|
|
|
# This is required for the server to know where to send events
|
|
|
|
|
await self._register_webhook()
|
|
|
|
|
|
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def disconnect(self) -> None:
|
2026-04-09 12:21:25 +00:00
|
|
|
# Unregister webhook before cleaning up
|
|
|
|
|
await self._unregister_webhook()
|
|
|
|
|
|
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
if self.client:
|
|
|
|
|
await self.client.aclose()
|
|
|
|
|
self.client = None
|
|
|
|
|
if self._runner:
|
|
|
|
|
await self._runner.cleanup()
|
|
|
|
|
self._runner = None
|
|
|
|
|
self._mark_disconnected()
|
|
|
|
|
|
2026-04-10 03:18:03 -07:00
|
|
|
@property
|
|
|
|
|
def _webhook_url(self) -> str:
|
|
|
|
|
"""Compute the external webhook URL for BlueBubbles registration."""
|
|
|
|
|
host = self.webhook_host
|
|
|
|
|
if host in ("0.0.0.0", "127.0.0.1", "localhost", "::"):
|
|
|
|
|
host = "localhost"
|
|
|
|
|
return f"http://{host}:{self.webhook_port}{self.webhook_path}"
|
|
|
|
|
|
fix(gateway/bluebubbles): embed password in registered webhook URL for inbound auth
When BlueBubbles posts webhook events to the adapter, it uses the exact
URL registered via /api/v1/webhook — and BB's registration API does not
support custom headers. The adapter currently registers the bare URL
(no credentials), but then requires password auth on inbound POSTs,
rejecting every webhook with HTTP 401.
This is masked on fresh BB installs by a race condition: the webhook
might register once with a prior (possibly patched) URL and keep working
until the first restart. On v0.9.0, _unregister_webhook runs on clean
shutdown, so the next startup re-registers with the bare URL and the
401s begin. Users see the bot go silent with no obvious cause.
Root cause: there's no way to pass auth credentials from BB to the
webhook handler except via the URL itself. BB accepts query params and
preserves them on outbound POSTs.
## Fix
Introduce `_webhook_register_url` — the URL handed to BB's registration
API, with the configured password appended as a `?password=<value>`
query param. The existing webhook auth handler already accepts this
form (it reads `request.query.get("password")`), so no change to the
receive side is needed.
The bare `_webhook_url` is still used for logging and for binding the
local listener, so credentials don't leak into log output. Only the
registration/find/unregister paths use the password-bearing form.
## Notes
- Password is URL-encoded via urllib.parse.quote, handling special
characters (&, *, @, etc.) that would otherwise break parsing.
- Storing the password in BB's webhook table is not a new disclosure:
anyone with access to that table already has the BB admin password
(same credential used for every other API call).
- If `self.password` is empty (no auth configured), the register URL
is the bare URL — preserves current behavior for unauthenticated
local-only setups.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:39:51 -05:00
|
|
|
@property
|
|
|
|
|
def _webhook_register_url(self) -> str:
|
|
|
|
|
"""Webhook URL registered with BlueBubbles, including the password as
|
|
|
|
|
a query param so inbound webhook POSTs carry credentials.
|
|
|
|
|
|
|
|
|
|
BlueBubbles posts events to the exact URL registered via
|
|
|
|
|
``/api/v1/webhook``. Its webhook registration API does not support
|
|
|
|
|
custom headers, so embedding the password in the URL is the only
|
|
|
|
|
way to authenticate inbound webhooks without disabling auth.
|
|
|
|
|
"""
|
|
|
|
|
base = self._webhook_url
|
|
|
|
|
if self.password:
|
|
|
|
|
return f"{base}?password={quote(self.password, safe='')}"
|
|
|
|
|
return base
|
|
|
|
|
|
2026-04-10 03:18:03 -07:00
|
|
|
async def _find_registered_webhooks(self, url: str) -> list:
|
|
|
|
|
"""Return list of BB webhook entries matching *url*."""
|
|
|
|
|
try:
|
|
|
|
|
res = await self._api_get("/api/v1/webhook")
|
|
|
|
|
data = res.get("data")
|
|
|
|
|
if isinstance(data, list):
|
|
|
|
|
return [wh for wh in data if wh.get("url") == url]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return []
|
|
|
|
|
|
2026-04-09 12:21:25 +00:00
|
|
|
async def _register_webhook(self) -> bool:
|
|
|
|
|
"""Register this webhook URL with the BlueBubbles server.
|
|
|
|
|
|
|
|
|
|
BlueBubbles requires webhooks to be registered via API before
|
2026-04-10 03:18:03 -07:00
|
|
|
it will send events. Checks for an existing registration first
|
|
|
|
|
to avoid duplicates (e.g. after a crash without clean shutdown).
|
2026-04-09 12:21:25 +00:00
|
|
|
"""
|
|
|
|
|
if not self.client:
|
|
|
|
|
return False
|
|
|
|
|
|
fix(gateway/bluebubbles): embed password in registered webhook URL for inbound auth
When BlueBubbles posts webhook events to the adapter, it uses the exact
URL registered via /api/v1/webhook — and BB's registration API does not
support custom headers. The adapter currently registers the bare URL
(no credentials), but then requires password auth on inbound POSTs,
rejecting every webhook with HTTP 401.
This is masked on fresh BB installs by a race condition: the webhook
might register once with a prior (possibly patched) URL and keep working
until the first restart. On v0.9.0, _unregister_webhook runs on clean
shutdown, so the next startup re-registers with the bare URL and the
401s begin. Users see the bot go silent with no obvious cause.
Root cause: there's no way to pass auth credentials from BB to the
webhook handler except via the URL itself. BB accepts query params and
preserves them on outbound POSTs.
## Fix
Introduce `_webhook_register_url` — the URL handed to BB's registration
API, with the configured password appended as a `?password=<value>`
query param. The existing webhook auth handler already accepts this
form (it reads `request.query.get("password")`), so no change to the
receive side is needed.
The bare `_webhook_url` is still used for logging and for binding the
local listener, so credentials don't leak into log output. Only the
registration/find/unregister paths use the password-bearing form.
## Notes
- Password is URL-encoded via urllib.parse.quote, handling special
characters (&, *, @, etc.) that would otherwise break parsing.
- Storing the password in BB's webhook table is not a new disclosure:
anyone with access to that table already has the BB admin password
(same credential used for every other API call).
- If `self.password` is empty (no auth configured), the register URL
is the bare URL — preserves current behavior for unauthenticated
local-only setups.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:39:51 -05:00
|
|
|
webhook_url = self._webhook_register_url
|
2026-04-10 03:18:03 -07:00
|
|
|
|
|
|
|
|
# Crash resilience — reuse an existing registration if present
|
|
|
|
|
existing = await self._find_registered_webhooks(webhook_url)
|
|
|
|
|
if existing:
|
|
|
|
|
logger.info(
|
|
|
|
|
"[bluebubbles] webhook already registered: %s", webhook_url
|
|
|
|
|
)
|
|
|
|
|
return True
|
2026-04-09 12:21:25 +00:00
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
"url": webhook_url,
|
2026-04-14 10:35:22 -05:00
|
|
|
"events": ["new-message", "updated-message"],
|
2026-04-09 12:21:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
res = await self._api_post("/api/v1/webhook", payload)
|
2026-04-10 03:18:03 -07:00
|
|
|
status = res.get("status", 0)
|
|
|
|
|
if 200 <= status < 300:
|
2026-04-09 12:21:25 +00:00
|
|
|
logger.info(
|
2026-04-10 03:18:03 -07:00
|
|
|
"[bluebubbles] webhook registered with server: %s",
|
2026-04-09 12:21:25 +00:00
|
|
|
webhook_url,
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(
|
2026-04-10 03:18:03 -07:00
|
|
|
"[bluebubbles] webhook registration returned status %s: %s",
|
|
|
|
|
status,
|
2026-04-09 12:21:25 +00:00
|
|
|
res.get("message"),
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"[bluebubbles] failed to register webhook with server: %s",
|
|
|
|
|
exc,
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
async def _unregister_webhook(self) -> bool:
|
|
|
|
|
"""Unregister this webhook URL from the BlueBubbles server.
|
|
|
|
|
|
2026-04-10 03:18:03 -07:00
|
|
|
Removes *all* matching registrations to clean up any duplicates
|
|
|
|
|
left by prior crashes.
|
2026-04-09 12:21:25 +00:00
|
|
|
"""
|
|
|
|
|
if not self.client:
|
|
|
|
|
return False
|
|
|
|
|
|
fix(gateway/bluebubbles): embed password in registered webhook URL for inbound auth
When BlueBubbles posts webhook events to the adapter, it uses the exact
URL registered via /api/v1/webhook — and BB's registration API does not
support custom headers. The adapter currently registers the bare URL
(no credentials), but then requires password auth on inbound POSTs,
rejecting every webhook with HTTP 401.
This is masked on fresh BB installs by a race condition: the webhook
might register once with a prior (possibly patched) URL and keep working
until the first restart. On v0.9.0, _unregister_webhook runs on clean
shutdown, so the next startup re-registers with the bare URL and the
401s begin. Users see the bot go silent with no obvious cause.
Root cause: there's no way to pass auth credentials from BB to the
webhook handler except via the URL itself. BB accepts query params and
preserves them on outbound POSTs.
## Fix
Introduce `_webhook_register_url` — the URL handed to BB's registration
API, with the configured password appended as a `?password=<value>`
query param. The existing webhook auth handler already accepts this
form (it reads `request.query.get("password")`), so no change to the
receive side is needed.
The bare `_webhook_url` is still used for logging and for binding the
local listener, so credentials don't leak into log output. Only the
registration/find/unregister paths use the password-bearing form.
## Notes
- Password is URL-encoded via urllib.parse.quote, handling special
characters (&, *, @, etc.) that would otherwise break parsing.
- Storing the password in BB's webhook table is not a new disclosure:
anyone with access to that table already has the BB admin password
(same credential used for every other API call).
- If `self.password` is empty (no auth configured), the register URL
is the bare URL — preserves current behavior for unauthenticated
local-only setups.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:39:51 -05:00
|
|
|
webhook_url = self._webhook_register_url
|
2026-04-10 03:18:03 -07:00
|
|
|
removed = False
|
2026-04-09 12:21:25 +00:00
|
|
|
|
|
|
|
|
try:
|
2026-04-10 03:18:03 -07:00
|
|
|
for wh in await self._find_registered_webhooks(webhook_url):
|
|
|
|
|
wh_id = wh.get("id")
|
|
|
|
|
if wh_id:
|
|
|
|
|
res = await self.client.delete(
|
|
|
|
|
self._api_url(f"/api/v1/webhook/{wh_id}")
|
|
|
|
|
)
|
|
|
|
|
res.raise_for_status()
|
|
|
|
|
removed = True
|
|
|
|
|
if removed:
|
|
|
|
|
logger.info(
|
|
|
|
|
"[bluebubbles] webhook unregistered: %s", webhook_url
|
|
|
|
|
)
|
2026-04-09 12:21:25 +00:00
|
|
|
except Exception as exc:
|
|
|
|
|
logger.debug(
|
|
|
|
|
"[bluebubbles] failed to unregister webhook (non-critical): %s",
|
|
|
|
|
exc,
|
|
|
|
|
)
|
2026-04-10 03:18:03 -07:00
|
|
|
return removed
|
2026-04-09 12:21:25 +00:00
|
|
|
|
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Chat GUID resolution
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def _resolve_chat_guid(self, target: str) -> Optional[str]:
|
|
|
|
|
"""Resolve an email/phone to a BlueBubbles chat GUID.
|
|
|
|
|
|
|
|
|
|
If *target* already contains a semicolon (raw GUID format like
|
|
|
|
|
``iMessage;-;user@example.com``), it is returned as-is. Otherwise
|
|
|
|
|
the adapter queries the BlueBubbles chat list and matches on
|
|
|
|
|
``chatIdentifier`` or participant address.
|
|
|
|
|
"""
|
|
|
|
|
target = (target or "").strip()
|
|
|
|
|
if not target:
|
|
|
|
|
return None
|
|
|
|
|
# Already a raw GUID
|
|
|
|
|
if ";" in target:
|
|
|
|
|
return target
|
|
|
|
|
if target in self._guid_cache:
|
|
|
|
|
return self._guid_cache[target]
|
|
|
|
|
try:
|
|
|
|
|
payload = await self._api_post(
|
|
|
|
|
"/api/v1/chat/query",
|
|
|
|
|
{"limit": 100, "offset": 0, "with": ["participants"]},
|
|
|
|
|
)
|
|
|
|
|
for chat in payload.get("data", []) or []:
|
|
|
|
|
guid = chat.get("guid") or chat.get("chatGuid")
|
|
|
|
|
identifier = chat.get("chatIdentifier") or chat.get("identifier")
|
|
|
|
|
if identifier == target:
|
|
|
|
|
if guid:
|
|
|
|
|
self._guid_cache[target] = guid
|
|
|
|
|
return guid
|
|
|
|
|
for part in chat.get("participants", []) or []:
|
|
|
|
|
if (part.get("address") or "").strip() == target and guid:
|
|
|
|
|
self._guid_cache[target] = guid
|
|
|
|
|
return guid
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _create_chat_for_handle(
|
|
|
|
|
self, address: str, message: str
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
"""Create a new chat by sending the first message to *address*."""
|
|
|
|
|
payload = {
|
|
|
|
|
"addresses": [address],
|
|
|
|
|
"message": message,
|
|
|
|
|
"tempGuid": f"temp-{datetime.utcnow().timestamp()}",
|
|
|
|
|
}
|
|
|
|
|
try:
|
|
|
|
|
res = await self._api_post("/api/v1/chat/new", payload)
|
|
|
|
|
data = res.get("data") or {}
|
|
|
|
|
msg_id = data.get("guid") or data.get("messageGuid") or "ok"
|
|
|
|
|
return SendResult(success=True, message_id=str(msg_id), raw_response=res)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
return SendResult(success=False, error=str(exc))
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Text sending
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def send(
|
|
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
content: str,
|
|
|
|
|
reply_to: Optional[str] = None,
|
|
|
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
|
|
|
) -> SendResult:
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
text = strip_markdown(content or "")
|
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
if not text:
|
|
|
|
|
return SendResult(success=False, error="BlueBubbles send requires text")
|
|
|
|
|
chunks = self.truncate_message(text, max_length=self.MAX_MESSAGE_LENGTH)
|
|
|
|
|
last = SendResult(success=True)
|
|
|
|
|
for chunk in chunks:
|
|
|
|
|
guid = await self._resolve_chat_guid(chat_id)
|
|
|
|
|
if not guid:
|
|
|
|
|
# If the target looks like an address, try creating a new chat
|
|
|
|
|
if self._private_api_enabled and (
|
|
|
|
|
"@" in chat_id or re.match(r"^\+\d+", chat_id)
|
|
|
|
|
):
|
|
|
|
|
return await self._create_chat_for_handle(chat_id, chunk)
|
|
|
|
|
return SendResult(
|
|
|
|
|
success=False,
|
|
|
|
|
error=f"BlueBubbles chat not found for target: {chat_id}",
|
|
|
|
|
)
|
|
|
|
|
payload: Dict[str, Any] = {
|
|
|
|
|
"chatGuid": guid,
|
|
|
|
|
"tempGuid": f"temp-{datetime.utcnow().timestamp()}",
|
|
|
|
|
"message": chunk,
|
|
|
|
|
}
|
|
|
|
|
if reply_to and self._private_api_enabled and self._helper_connected:
|
|
|
|
|
payload["method"] = "private-api"
|
|
|
|
|
payload["selectedMessageGuid"] = reply_to
|
|
|
|
|
payload["partIndex"] = 0
|
|
|
|
|
try:
|
|
|
|
|
res = await self._api_post("/api/v1/message/text", payload)
|
|
|
|
|
data = res.get("data") or {}
|
|
|
|
|
msg_id = data.get("guid") or data.get("messageGuid") or "ok"
|
|
|
|
|
last = SendResult(
|
|
|
|
|
success=True, message_id=str(msg_id), raw_response=res
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
return SendResult(success=False, error=str(exc))
|
|
|
|
|
return last
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Media sending (outbound)
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def _send_attachment(
|
|
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
file_path: str,
|
|
|
|
|
filename: Optional[str] = None,
|
|
|
|
|
caption: Optional[str] = None,
|
|
|
|
|
is_audio_message: bool = False,
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
"""Send a file attachment via BlueBubbles multipart upload."""
|
|
|
|
|
if not self.client:
|
|
|
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
|
if not os.path.isfile(file_path):
|
|
|
|
|
return SendResult(success=False, error=f"File not found: {file_path}")
|
|
|
|
|
|
|
|
|
|
guid = await self._resolve_chat_guid(chat_id)
|
|
|
|
|
if not guid:
|
|
|
|
|
return SendResult(success=False, error=f"Chat not found: {chat_id}")
|
|
|
|
|
|
|
|
|
|
fname = filename or os.path.basename(file_path)
|
|
|
|
|
try:
|
|
|
|
|
with open(file_path, "rb") as f:
|
|
|
|
|
files = {"attachment": (fname, f, "application/octet-stream")}
|
|
|
|
|
data: Dict[str, str] = {
|
|
|
|
|
"chatGuid": guid,
|
|
|
|
|
"name": fname,
|
|
|
|
|
"tempGuid": uuid.uuid4().hex,
|
|
|
|
|
}
|
|
|
|
|
if is_audio_message:
|
|
|
|
|
data["isAudioMessage"] = "true"
|
|
|
|
|
res = await self.client.post(
|
|
|
|
|
self._api_url("/api/v1/message/attachment"),
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
timeout=120,
|
|
|
|
|
)
|
|
|
|
|
res.raise_for_status()
|
|
|
|
|
result = res.json()
|
|
|
|
|
|
|
|
|
|
if caption:
|
|
|
|
|
await self.send(chat_id, caption)
|
|
|
|
|
|
|
|
|
|
if result.get("status") == 200:
|
|
|
|
|
rdata = result.get("data") or {}
|
|
|
|
|
msg_id = rdata.get("guid") if isinstance(rdata, dict) else None
|
|
|
|
|
return SendResult(
|
|
|
|
|
success=True, message_id=msg_id, raw_response=result
|
|
|
|
|
)
|
|
|
|
|
return SendResult(
|
|
|
|
|
success=False,
|
|
|
|
|
error=result.get("message", "Attachment upload failed"),
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return SendResult(success=False, error=str(e))
|
|
|
|
|
|
|
|
|
|
async def send_image(
|
|
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
image_url: str,
|
|
|
|
|
caption: Optional[str] = None,
|
|
|
|
|
reply_to: Optional[str] = None,
|
|
|
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
try:
|
|
|
|
|
from gateway.platforms.base import cache_image_from_url
|
|
|
|
|
|
|
|
|
|
local_path = await cache_image_from_url(image_url)
|
|
|
|
|
return await self._send_attachment(chat_id, local_path, caption=caption)
|
|
|
|
|
except Exception:
|
|
|
|
|
return await super().send_image(chat_id, image_url, caption, reply_to)
|
|
|
|
|
|
|
|
|
|
async def send_image_file(
|
|
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
image_path: str,
|
|
|
|
|
caption: Optional[str] = None,
|
|
|
|
|
reply_to: Optional[str] = None,
|
|
|
|
|
**kwargs,
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
return await self._send_attachment(chat_id, image_path, caption=caption)
|
|
|
|
|
|
|
|
|
|
async def send_voice(
|
|
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
audio_path: str,
|
|
|
|
|
caption: Optional[str] = None,
|
|
|
|
|
reply_to: Optional[str] = None,
|
|
|
|
|
**kwargs,
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
return await self._send_attachment(
|
|
|
|
|
chat_id, audio_path, caption=caption, is_audio_message=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def send_video(
|
|
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
video_path: str,
|
|
|
|
|
caption: Optional[str] = None,
|
|
|
|
|
reply_to: Optional[str] = None,
|
|
|
|
|
**kwargs,
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
return await self._send_attachment(chat_id, video_path, caption=caption)
|
|
|
|
|
|
|
|
|
|
async def send_document(
|
|
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
file_path: str,
|
|
|
|
|
caption: Optional[str] = None,
|
|
|
|
|
file_name: Optional[str] = None,
|
|
|
|
|
reply_to: Optional[str] = None,
|
|
|
|
|
**kwargs,
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
return await self._send_attachment(
|
|
|
|
|
chat_id, file_path, filename=file_name, caption=caption
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def send_animation(
|
|
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
animation_url: str,
|
|
|
|
|
caption: Optional[str] = None,
|
|
|
|
|
reply_to: Optional[str] = None,
|
|
|
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
return await self.send_image(
|
|
|
|
|
chat_id, animation_url, caption, reply_to, metadata
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Typing indicators
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
|
|
|
|
if not self._private_api_enabled or not self._helper_connected or not self.client:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
guid = await self._resolve_chat_guid(chat_id)
|
|
|
|
|
if guid:
|
|
|
|
|
encoded = quote(guid, safe="")
|
|
|
|
|
await self.client.post(
|
|
|
|
|
self._api_url(f"/api/v1/chat/{encoded}/typing"), timeout=5
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
async def stop_typing(self, chat_id: str) -> None:
|
|
|
|
|
if not self._private_api_enabled or not self._helper_connected or not self.client:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
guid = await self._resolve_chat_guid(chat_id)
|
|
|
|
|
if guid:
|
|
|
|
|
encoded = quote(guid, safe="")
|
|
|
|
|
await self.client.delete(
|
|
|
|
|
self._api_url(f"/api/v1/chat/{encoded}/typing"), timeout=5
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Read receipts
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def mark_read(self, chat_id: str) -> bool:
|
|
|
|
|
if not self._private_api_enabled or not self._helper_connected or not self.client:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
guid = await self._resolve_chat_guid(chat_id)
|
|
|
|
|
if guid:
|
|
|
|
|
encoded = quote(guid, safe="")
|
|
|
|
|
await self.client.post(
|
|
|
|
|
self._api_url(f"/api/v1/chat/{encoded}/read"), timeout=5
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Tapback reactions
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Chat info
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
|
|
|
is_group = ";+;" in (chat_id or "")
|
|
|
|
|
info: Dict[str, Any] = {
|
|
|
|
|
"name": chat_id,
|
|
|
|
|
"type": "group" if is_group else "dm",
|
|
|
|
|
}
|
|
|
|
|
try:
|
|
|
|
|
guid = await self._resolve_chat_guid(chat_id)
|
|
|
|
|
if guid:
|
|
|
|
|
encoded = quote(guid, safe="")
|
|
|
|
|
res = await self._api_get(
|
|
|
|
|
f"/api/v1/chat/{encoded}?with=participants"
|
|
|
|
|
)
|
|
|
|
|
data = (res or {}).get("data", {})
|
|
|
|
|
display_name = (
|
|
|
|
|
data.get("displayName")
|
|
|
|
|
or data.get("chatIdentifier")
|
|
|
|
|
or chat_id
|
|
|
|
|
)
|
|
|
|
|
participants = []
|
|
|
|
|
for p in data.get("participants", []) or []:
|
|
|
|
|
addr = (p.get("address") or "").strip()
|
|
|
|
|
if addr:
|
|
|
|
|
participants.append(addr)
|
|
|
|
|
info["name"] = display_name
|
|
|
|
|
if participants:
|
|
|
|
|
info["participants"] = participants
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return info
|
|
|
|
|
|
|
|
|
|
def format_message(self, content: str) -> str:
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
return strip_markdown(content)
|
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Inbound attachment downloading (from #4588)
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def _download_attachment(
|
|
|
|
|
self, att_guid: str, att_meta: Dict[str, Any]
|
|
|
|
|
) -> Optional[str]:
|
|
|
|
|
"""Download an attachment from BlueBubbles and cache it locally.
|
|
|
|
|
|
|
|
|
|
Returns the local file path on success, None on failure.
|
|
|
|
|
"""
|
|
|
|
|
if not self.client:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
encoded = quote(att_guid, safe="")
|
|
|
|
|
resp = await self.client.get(
|
|
|
|
|
self._api_url(f"/api/v1/attachment/{encoded}/download"),
|
|
|
|
|
timeout=60,
|
|
|
|
|
follow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
data = resp.content
|
|
|
|
|
|
|
|
|
|
mime = (att_meta.get("mimeType") or "").lower()
|
|
|
|
|
transfer_name = att_meta.get("transferName", "")
|
|
|
|
|
|
|
|
|
|
if mime.startswith("image/"):
|
|
|
|
|
ext_map = {
|
|
|
|
|
"image/jpeg": ".jpg",
|
|
|
|
|
"image/png": ".png",
|
|
|
|
|
"image/gif": ".gif",
|
|
|
|
|
"image/webp": ".webp",
|
|
|
|
|
"image/heic": ".jpg",
|
|
|
|
|
"image/heif": ".jpg",
|
|
|
|
|
"image/tiff": ".jpg",
|
|
|
|
|
}
|
|
|
|
|
ext = ext_map.get(mime, ".jpg")
|
|
|
|
|
return cache_image_from_bytes(data, ext)
|
|
|
|
|
|
|
|
|
|
if mime.startswith("audio/"):
|
|
|
|
|
ext_map = {
|
|
|
|
|
"audio/mp3": ".mp3",
|
|
|
|
|
"audio/mpeg": ".mp3",
|
|
|
|
|
"audio/ogg": ".ogg",
|
|
|
|
|
"audio/wav": ".wav",
|
|
|
|
|
"audio/x-caf": ".mp3",
|
|
|
|
|
"audio/mp4": ".m4a",
|
|
|
|
|
"audio/aac": ".m4a",
|
|
|
|
|
}
|
|
|
|
|
ext = ext_map.get(mime, ".mp3")
|
|
|
|
|
return cache_audio_from_bytes(data, ext)
|
|
|
|
|
|
|
|
|
|
# Videos, documents, and everything else
|
|
|
|
|
filename = transfer_name or f"file_{uuid.uuid4().hex[:8]}"
|
|
|
|
|
return cache_document_from_bytes(data, filename)
|
|
|
|
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"[bluebubbles] failed to download attachment %s: %s",
|
|
|
|
|
_redact(att_guid),
|
|
|
|
|
exc,
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Webhook handling
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _extract_payload_record(
|
|
|
|
|
self, payload: Dict[str, Any]
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
data = payload.get("data")
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
return data
|
|
|
|
|
if isinstance(data, list):
|
|
|
|
|
for item in data:
|
|
|
|
|
if isinstance(item, dict):
|
|
|
|
|
return item
|
|
|
|
|
if isinstance(payload.get("message"), dict):
|
|
|
|
|
return payload.get("message")
|
|
|
|
|
return payload if isinstance(payload, dict) else None
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _value(*candidates: Any) -> Optional[str]:
|
|
|
|
|
for candidate in candidates:
|
|
|
|
|
if isinstance(candidate, str) and candidate.strip():
|
|
|
|
|
return candidate.strip()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _handle_webhook(self, request):
|
|
|
|
|
from aiohttp import web
|
|
|
|
|
|
|
|
|
|
token = (
|
|
|
|
|
request.query.get("password")
|
|
|
|
|
or request.query.get("guid")
|
|
|
|
|
or request.headers.get("x-password")
|
|
|
|
|
or request.headers.get("x-guid")
|
|
|
|
|
or request.headers.get("x-bluebubbles-guid")
|
|
|
|
|
)
|
|
|
|
|
if token != self.password:
|
|
|
|
|
return web.json_response({"error": "unauthorized"}, status=401)
|
|
|
|
|
try:
|
|
|
|
|
raw = await request.read()
|
|
|
|
|
body = raw.decode("utf-8", errors="replace")
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(body)
|
|
|
|
|
except Exception:
|
|
|
|
|
from urllib.parse import parse_qs
|
|
|
|
|
|
|
|
|
|
form = parse_qs(body)
|
|
|
|
|
payload_str = (
|
|
|
|
|
form.get("payload")
|
|
|
|
|
or form.get("data")
|
|
|
|
|
or form.get("message")
|
|
|
|
|
or [""]
|
|
|
|
|
)[0]
|
|
|
|
|
payload = json.loads(payload_str) if payload_str else {}
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error("[bluebubbles] webhook parse error: %s", exc)
|
|
|
|
|
return web.json_response({"error": "invalid payload"}, status=400)
|
|
|
|
|
|
|
|
|
|
event_type = self._value(payload.get("type"), payload.get("event")) or ""
|
|
|
|
|
# Only process message events; silently acknowledge everything else
|
|
|
|
|
if event_type and event_type not in _MESSAGE_EVENTS:
|
|
|
|
|
return web.Response(text="ok")
|
|
|
|
|
|
|
|
|
|
record = self._extract_payload_record(payload) or {}
|
|
|
|
|
is_from_me = bool(
|
|
|
|
|
record.get("isFromMe")
|
|
|
|
|
or record.get("fromMe")
|
|
|
|
|
or record.get("is_from_me")
|
|
|
|
|
)
|
|
|
|
|
if is_from_me:
|
|
|
|
|
return web.Response(text="ok")
|
|
|
|
|
|
|
|
|
|
# Skip tapback reactions delivered as messages
|
|
|
|
|
assoc_type = record.get("associatedMessageType")
|
|
|
|
|
if isinstance(assoc_type, int) and assoc_type in {
|
|
|
|
|
**_TAPBACK_ADDED,
|
|
|
|
|
**_TAPBACK_REMOVED,
|
|
|
|
|
}:
|
|
|
|
|
return web.Response(text="ok")
|
|
|
|
|
|
|
|
|
|
text = (
|
|
|
|
|
self._value(
|
|
|
|
|
record.get("text"), record.get("message"), record.get("body")
|
|
|
|
|
)
|
|
|
|
|
or ""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# --- Inbound attachment handling ---
|
|
|
|
|
attachments = record.get("attachments") or []
|
|
|
|
|
media_urls: List[str] = []
|
|
|
|
|
media_types: List[str] = []
|
|
|
|
|
msg_type = MessageType.TEXT
|
|
|
|
|
|
|
|
|
|
for att in attachments:
|
|
|
|
|
att_guid = att.get("guid", "")
|
|
|
|
|
if not att_guid:
|
|
|
|
|
continue
|
|
|
|
|
cached = await self._download_attachment(att_guid, att)
|
|
|
|
|
if cached:
|
|
|
|
|
mime = (att.get("mimeType") or "").lower()
|
|
|
|
|
media_urls.append(cached)
|
|
|
|
|
media_types.append(mime)
|
|
|
|
|
if mime.startswith("image/"):
|
|
|
|
|
msg_type = MessageType.PHOTO
|
|
|
|
|
elif mime.startswith("audio/") or (att.get("uti") or "").endswith(
|
|
|
|
|
"caf"
|
|
|
|
|
):
|
|
|
|
|
msg_type = MessageType.VOICE
|
|
|
|
|
elif mime.startswith("video/"):
|
|
|
|
|
msg_type = MessageType.VIDEO
|
|
|
|
|
else:
|
|
|
|
|
msg_type = MessageType.DOCUMENT
|
|
|
|
|
|
|
|
|
|
# With multiple attachments, prefer PHOTO if any images present
|
|
|
|
|
if len(media_urls) > 1:
|
|
|
|
|
mime_prefixes = {(m or "").split("/")[0] for m in media_types}
|
|
|
|
|
if "image" in mime_prefixes:
|
|
|
|
|
msg_type = MessageType.PHOTO
|
|
|
|
|
|
|
|
|
|
if not text and media_urls:
|
|
|
|
|
text = "(attachment)"
|
|
|
|
|
# --- End attachment handling ---
|
|
|
|
|
|
|
|
|
|
chat_guid = self._value(
|
|
|
|
|
record.get("chatGuid"),
|
|
|
|
|
payload.get("chatGuid"),
|
|
|
|
|
record.get("chat_guid"),
|
|
|
|
|
payload.get("chat_guid"),
|
|
|
|
|
payload.get("guid"),
|
|
|
|
|
)
|
2026-04-14 10:30:58 -05:00
|
|
|
# Fallback: BlueBubbles v1.9+ webhook payloads omit top-level chatGuid;
|
|
|
|
|
# the chat GUID is nested under data.chats[0].guid instead.
|
|
|
|
|
if not chat_guid:
|
|
|
|
|
_chats = record.get("chats") or []
|
|
|
|
|
if _chats and isinstance(_chats[0], dict):
|
|
|
|
|
chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid")
|
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
chat_identifier = self._value(
|
|
|
|
|
record.get("chatIdentifier"),
|
|
|
|
|
record.get("identifier"),
|
|
|
|
|
payload.get("chatIdentifier"),
|
|
|
|
|
payload.get("identifier"),
|
|
|
|
|
)
|
|
|
|
|
sender = (
|
|
|
|
|
self._value(
|
|
|
|
|
record.get("handle", {}).get("address")
|
|
|
|
|
if isinstance(record.get("handle"), dict)
|
|
|
|
|
else None,
|
|
|
|
|
record.get("sender"),
|
|
|
|
|
record.get("from"),
|
|
|
|
|
record.get("address"),
|
|
|
|
|
)
|
|
|
|
|
or chat_identifier
|
|
|
|
|
or chat_guid
|
|
|
|
|
)
|
|
|
|
|
if not (chat_guid or chat_identifier) and sender:
|
|
|
|
|
chat_identifier = sender
|
|
|
|
|
if not sender or not (chat_guid or chat_identifier) or not text:
|
|
|
|
|
return web.json_response({"error": "missing message fields"}, status=400)
|
|
|
|
|
|
|
|
|
|
session_chat_id = chat_guid or chat_identifier
|
|
|
|
|
is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or ""))
|
|
|
|
|
source = self.build_source(
|
|
|
|
|
chat_id=session_chat_id,
|
|
|
|
|
chat_name=chat_identifier or sender,
|
|
|
|
|
chat_type="group" if is_group else "dm",
|
|
|
|
|
user_id=sender,
|
|
|
|
|
user_name=sender,
|
|
|
|
|
chat_id_alt=chat_identifier,
|
|
|
|
|
)
|
|
|
|
|
event = MessageEvent(
|
|
|
|
|
text=text,
|
|
|
|
|
message_type=msg_type,
|
|
|
|
|
source=source,
|
|
|
|
|
raw_message=payload,
|
|
|
|
|
message_id=self._value(
|
|
|
|
|
record.get("guid"),
|
|
|
|
|
record.get("messageGuid"),
|
|
|
|
|
record.get("id"),
|
|
|
|
|
),
|
|
|
|
|
reply_to_message_id=self._value(
|
|
|
|
|
record.get("threadOriginatorGuid"),
|
|
|
|
|
record.get("associatedMessageGuid"),
|
|
|
|
|
),
|
|
|
|
|
media_urls=media_urls,
|
|
|
|
|
media_types=media_types,
|
|
|
|
|
)
|
|
|
|
|
task = asyncio.create_task(self.handle_message(event))
|
|
|
|
|
self._background_tasks.add(task)
|
|
|
|
|
task.add_done_callback(self._background_tasks.discard)
|
|
|
|
|
|
|
|
|
|
# Fire-and-forget read receipt
|
|
|
|
|
if self.send_read_receipts and session_chat_id:
|
|
|
|
|
asyncio.create_task(self.mark_read(session_chat_id))
|
|
|
|
|
|
|
|
|
|
return web.Response(text="ok")
|
2026-04-09 12:21:25 +00:00
|
|
|
|