diff --git a/config.example.json b/config.example.json index 665ee49..44822bf 100644 --- a/config.example.json +++ b/config.example.json @@ -19,7 +19,8 @@ "max_session_messages": null, "permission_mode": "bypassPermissions", - "claude_root_permission_mode": "dontAsk", + "claude_root_permission_mode": "bypassPermissions", + "claude_root_force_bypass_via_is_sandbox": true, "cli_timeout": 600.0, "reasoning_effort": "medium", "file_access": "all", diff --git a/controlmesh/__init__.py b/controlmesh/__init__.py index 2a9fb58..09de7e3 100644 --- a/controlmesh/__init__.py +++ b/controlmesh/__init__.py @@ -1,3 +1,3 @@ """ControlMesh public package and CLI entrypoint surface.""" -__version__ = "0.33.0" +__version__ = "0.33.2" diff --git a/controlmesh/cli/service.py b/controlmesh/cli/service.py index f059b30..d02c756 100644 --- a/controlmesh/cli/service.py +++ b/controlmesh/cli/service.py @@ -408,19 +408,19 @@ def resolve_runtime_provider_target( and preflight_requested_model and not probe_opencode_model_sync(requested_model) ): - msg = f"error:opencode_model_unrunnable model={requested_model}" - raise ValueError(msg) + logger.warning( + "OpenCode preflight probe failed; continuing with configured model " + "provider=%s model=%s", + provider, + requested_model, + ) return provider, requested_model if provider == "opencode": model = resolve_opencode_runnable_model_sync() if model: return provider, model - if ( - self._config.provider == "opencode" - and self._config.default_model - and probe_opencode_model_sync(self._config.default_model) - ): + if self._config.provider == "opencode" and self._config.default_model: return provider, self._config.default_model msg = "error:opencode_default_model_unresolved" raise ValueError(msg) diff --git a/controlmesh/config.py b/controlmesh/config.py index cb0929e..d2c398f 100644 --- a/controlmesh/config.py +++ b/controlmesh/config.py @@ -203,6 +203,22 @@ class FeishuConfig(BaseModel): validation_alias=AliasChoices("listener_max_body_bytes", "callback_max_body_bytes"), ) allow_from: list[str] = Field(default_factory=list) + allowed_chat_ids: list[str] = Field( + default_factory=list, + validation_alias=AliasChoices("allowed_chat_ids", "allowedChatIds"), + ) + group_policy: Literal["open", "allowlist", "disabled"] = Field( + default="disabled", + validation_alias=AliasChoices("group_policy", "groupPolicy"), + ) + group_message_mode: Literal["passive", "mention_only", "mention_patterns"] = Field( + default="mention_only", + validation_alias=AliasChoices("group_message_mode", "groupMessageMode"), + ) + mention_patterns: list[str] = Field( + default_factory=list, + validation_alias=AliasChoices("mention_patterns", "mentionPatterns"), + ) group_reply_all: bool = False thread_isolation: bool = False reply_to_trigger: bool = True diff --git a/controlmesh/messenger/feishu/bot.py b/controlmesh/messenger/feishu/bot.py index 242ce12..ee09ff1 100644 --- a/controlmesh/messenger/feishu/bot.py +++ b/controlmesh/messenger/feishu/bot.py @@ -242,6 +242,8 @@ class FeishuIncomingText: parent_id: str | None = None quote_summary: str | None = None post_title: str | None = None + chat_type: str | None = None + mentions: tuple[str, ...] = () class FeishuNotificationService: @@ -428,6 +430,13 @@ async def handle_incoming_event(self, payload: dict[str, Any]) -> None: message = await self._parse_incoming_message(payload) if message is None: return + if not self._should_deliver_group_message(message): + logger.info( + "Observed passive Feishu group message chat_id=%s message_id=%s", + message.chat_id, + message.message_id, + ) + return if self._is_old_message(message): logger.info( "Ignoring old Feishu message after startup chat_id=%s message_id=%s", @@ -1943,6 +1952,60 @@ def _sender_allowed(self, sender_id: str) -> bool: allow_from = self._config.feishu.allow_from return not allow_from or sender_id in allow_from + def _should_deliver_group_message(self, message: FeishuIncomingText) -> bool: + if not self._is_group_chat(message): + return True + policy = self._config.feishu.group_policy + if policy == "disabled": + return False + if policy == "allowlist" and not self._group_chat_allowed(message.chat_id): + return False + if self._is_standalone_slash_command(message.text): + return True + mode = self._config.feishu.group_message_mode + if mode == "passive": + return True + if mode == "mention_only": + return self._message_mentions_bot(message) + if mode == "mention_patterns": + return self._message_mentions_bot(message) or self._matches_mention_patterns(message.text) + return False + + @staticmethod + def _is_group_chat(message: FeishuIncomingText) -> bool: + chat_type = (message.chat_type or "").strip().lower() + return chat_type not in {"", "p2p"} + + def _group_chat_allowed(self, chat_id: str) -> bool: + allowed = self._config.feishu.allowed_chat_ids + return not allowed or chat_id in allowed + + @staticmethod + def _is_standalone_slash_command(text: str) -> bool: + cleaned = " ".join(text.split()).strip() + return cleaned.startswith("/") and len(cleaned) > 1 + + def _message_mentions_bot(self, message: FeishuIncomingText) -> bool: + if message.mentions: + return True + return bool(message.root_id or message.parent_id) + + def _matches_mention_patterns(self, text: str) -> bool: + cleaned = " ".join(text.split()).strip() + if not cleaned: + return False + haystack = cleaned.casefold() + for raw_pattern in self._config.feishu.mention_patterns: + pattern = raw_pattern.strip() + if not pattern: + continue + candidates = {pattern.casefold()} + if not pattern.startswith("@"): + candidates.add(f"@{pattern}".casefold()) + if any(candidate in haystack for candidate in candidates): + return True + return False + async def _parse_incoming_message(self, payload: dict[str, Any]) -> FeishuIncomingText | None: header = payload.get("header") event = payload.get("event") @@ -2004,6 +2067,8 @@ async def _parse_incoming_message(self, payload: dict[str, Any]) -> FeishuIncomi parent_id=parent_id if isinstance(parent_id, str) and parent_id else None, quote_summary=parsed_content.quote_summary if parsed_content else None, post_title=parsed_content.post_title if parsed_content else None, + chat_type=parsed_content.chat_type if parsed_content else None, + mentions=parsed_content.mentions if parsed_content else (), ) @staticmethod diff --git a/controlmesh/messenger/feishu/message_context.py b/controlmesh/messenger/feishu/message_context.py index 04d5a95..27b2789 100644 --- a/controlmesh/messenger/feishu/message_context.py +++ b/controlmesh/messenger/feishu/message_context.py @@ -20,6 +20,8 @@ class ParsedFeishuContent: message_type: str post_title: str | None = None quote_summary: str | None = None + chat_type: str | None = None + mentions: tuple[str, ...] = () def extract_feishu_content_from_event( @@ -33,13 +35,24 @@ def extract_feishu_content_from_event( if not kit_context: return fallback kit_text = _kit_prompt_text(kit_context) + chat_type = kit_context.get("chat_type") + mentions = _kit_mentions(kit_context) if message_type == "text" and kit_text: return ParsedFeishuContent( text=kit_text, message_type=message_type, quote_summary=fallback.quote_summary, + chat_type=chat_type if isinstance(chat_type, str) and chat_type else None, + mentions=mentions, ) - return fallback + return ParsedFeishuContent( + text=fallback.text, + message_type=fallback.message_type, + post_title=fallback.post_title, + quote_summary=fallback.quote_summary, + chat_type=chat_type if isinstance(chat_type, str) and chat_type else None, + mentions=mentions, + ) def extract_feishu_content(message_type: str, raw_content: object) -> ParsedFeishuContent: @@ -302,3 +315,17 @@ def _kit_prompt_text(context: dict[str, Any]) -> str: if isinstance(text, str): return text.strip() return "" + + +def _kit_mentions(context: dict[str, Any]) -> tuple[str, ...]: + raw_mentions = context.get("mentions") + if not isinstance(raw_mentions, list): + return () + values: list[str] = [] + for item in raw_mentions: + if not isinstance(item, dict): + continue + open_id = item.get("open_id") + if isinstance(open_id, str) and open_id.strip(): + values.append(open_id.strip()) + return tuple(dict.fromkeys(values)) diff --git a/controlmesh/messenger/telegram/app.py b/controlmesh/messenger/telegram/app.py index 8ec6214..8cd0845 100644 --- a/controlmesh/messenger/telegram/app.py +++ b/controlmesh/messenger/telegram/app.py @@ -232,12 +232,12 @@ async def make_request( self, bot: Bot, method: TelegramMethod[object], - request_timeout: int | None = None, + timeout: int | None = None, ) -> object: if isinstance(method, GetUpdates): self._on_poll_started(method) try: - result = await self._inner.make_request(bot, method, request_timeout) + result = await self._inner.make_request(bot, method, timeout=timeout) except asyncio.CancelledError: raise except Exception as exc: @@ -245,7 +245,24 @@ async def make_request( raise self._on_poll_succeeded(method, result) return result - return await self._inner.make_request(bot, method, request_timeout) + return await self._inner.make_request(bot, method, timeout=timeout) + + async def stream_content( + self, + url: str, + headers: dict[str, object] | None = None, + timeout: int = 30, + chunk_size: int = 65536, + raise_for_status: bool = True, + ): + async for chunk in self._inner.stream_content( + url, + headers=headers, + timeout=timeout, + chunk_size=chunk_size, + raise_for_status=raise_for_status, + ): + yield chunk def __getattr__(self, name: str) -> object: return getattr(self._inner, name) @@ -1567,7 +1584,9 @@ async def _on_message(self, message: Message) -> None: if self._inbound_spool is None: self._enqueue_frontstage_run(message, key, text, thread_id=thread_id) return - enqueued = self._inbound_spool.enqueue([message.model_dump(mode="json")]) + enqueued = self._inbound_spool.enqueue( + [message.model_dump(mode="python", exclude_none=True)] + ) self._last_inbound_spool_stats = self._inbound_spool.stats() if enqueued: logger.debug( diff --git a/controlmesh/messenger/telegram/edit_streaming.py b/controlmesh/messenger/telegram/edit_streaming.py index e408517..9672371 100644 --- a/controlmesh/messenger/telegram/edit_streaming.py +++ b/controlmesh/messenger/telegram/edit_streaming.py @@ -15,6 +15,7 @@ from aiogram.enums import ParseMode from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter +from aiogram.types import ReplyParameters from controlmesh.messenger.telegram.buttons import extract_buttons from controlmesh.messenger.telegram.formatting import ( @@ -287,7 +288,15 @@ async def _create_message(self, text: str) -> None: return try: if self._s.messages_sent == 0 and self._reply_to is not None: - msg = await self._reply_to.answer(display, parse_mode=ParseMode.HTML) + kwargs = { + "chat_id": self._chat_id, + "text": display, + "parse_mode": ParseMode.HTML, + "reply_parameters": ReplyParameters(message_id=self._reply_to.message_id), + } + if self._thread_id is not None: + kwargs["message_thread_id"] = self._thread_id + msg = await self._bot.send_message(**kwargs) else: msg = await self._bot.send_message( chat_id=self._chat_id, @@ -306,12 +315,14 @@ async def _create_message(self, text: str) -> None: async def _create_message_plain(self, text: str) -> None: """Fallback: send without HTML parse mode.""" try: - msg = await self._bot.send_message( - chat_id=self._chat_id, - text=text[:TELEGRAM_MSG_LIMIT], - parse_mode=None, - message_thread_id=self._thread_id, - ) + kwargs = { + "chat_id": self._chat_id, + "text": text[:TELEGRAM_MSG_LIMIT], + "parse_mode": None, + } + if self._thread_id is not None: + kwargs["message_thread_id"] = self._thread_id + msg = await self._bot.send_message(**kwargs) self._s.active_msg = msg remember_sent_message(self._bot, self._chat_id, msg) self._s.messages_sent += 1 @@ -388,19 +399,23 @@ async def _send_new(self, formatted: str) -> None: if not display.strip(): continue try: - await self._bot.send_message( - chat_id=self._chat_id, - text=display, - parse_mode=ParseMode.HTML, - message_thread_id=self._thread_id, - ) + kwargs = { + "chat_id": self._chat_id, + "text": display, + "parse_mode": ParseMode.HTML, + } + if self._thread_id is not None: + kwargs["message_thread_id"] = self._thread_id + await self._bot.send_message(**kwargs) except TelegramBadRequest: - await self._bot.send_message( - chat_id=self._chat_id, - text=display, - parse_mode=None, - message_thread_id=self._thread_id, - ) + kwargs = { + "chat_id": self._chat_id, + "text": display, + "parse_mode": None, + } + if self._thread_id is not None: + kwargs["message_thread_id"] = self._thread_id + await self._bot.send_message(**kwargs) self._s.messages_sent += 1 diff --git a/controlmesh/messenger/telegram/inbound_spool.py b/controlmesh/messenger/telegram/inbound_spool.py index c7d699a..fd816a2 100644 --- a/controlmesh/messenger/telegram/inbound_spool.py +++ b/controlmesh/messenger/telegram/inbound_spool.py @@ -115,7 +115,15 @@ def enqueue(self, raw_messages: list[dict[str, object]] | tuple[dict[str, object path = self.pending_dir / f"{entry['spool_id']}.json" if path.exists(): continue - path.write_text(json.dumps(entry, ensure_ascii=True, sort_keys=True), encoding="utf-8") + path.write_text( + json.dumps( + entry, + ensure_ascii=True, + sort_keys=True, + default=_json_fallback, + ), + encoding="utf-8", + ) self._protect_file(path) pending_keys.add(dedupe_key) enqueued += 1 @@ -429,5 +437,18 @@ def _dedupe_key(chat_id: int, message_id: int) -> str: return f"{chat_id}:{message_id}" +def _json_fallback(value: object) -> object: + """Best-effort JSON fallback for aiogram sentinel/default objects.""" + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if isinstance(value, Path): + return str(value) + if isinstance(value, dict): + return {str(key): _json_fallback(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_json_fallback(item) for item in value] + return repr(value) + + def _lane_claim_name(lane_key: str) -> str: return hashlib.sha256(lane_key.encode("utf-8")).hexdigest()[:24] diff --git a/controlmesh/messenger/telegram/startup.py b/controlmesh/messenger/telegram/startup.py index 10a43e0..73e8dc4 100644 --- a/controlmesh/messenger/telegram/startup.py +++ b/controlmesh/messenger/telegram/startup.py @@ -135,7 +135,6 @@ async def run_startup(bot: TelegramBot) -> None: await bot._sync_commands() bot._restart_watcher = asyncio.create_task(bot._watch_restart_marker()) - bot._polling_watchdog = asyncio.create_task(bot._watch_polling_liveness()) # Audit groups on startup and start periodic 24h check await bot.audit_groups() diff --git a/controlmesh/orchestrator/core.py b/controlmesh/orchestrator/core.py index 48936ab..9e52107 100644 --- a/controlmesh/orchestrator/core.py +++ b/controlmesh/orchestrator/core.py @@ -62,6 +62,7 @@ ReadinessStatus, render_fallback_notice, ) +from controlmesh.provider_binding import normalize_provider_name from controlmesh.runtime.models import RuntimeEvent from controlmesh.runtime.store import RuntimeEventStore from controlmesh.orchestrator.directives import ParsedDirectives, parse_directives @@ -369,6 +370,25 @@ def set_bootstrap_health(self, health: BootstrapHealth) -> None: """Inject structured startup/provider readiness health.""" self._bootstrap_health = health + def refresh_bootstrap_health(self) -> None: + """Recompute bootstrap health from the current in-memory config/auth state.""" + from controlmesh.provider_health import assess_bootstrap_health + + try: + default_model, default_provider = self.resolve_runtime_target(self._config.model) + except ValueError: + default_model, default_provider = self._config.model, self._config.provider + + auth_results = self._providers.auth_results_snapshot() + self._bootstrap_health = assess_bootstrap_health( + configured_provider=self._config.provider, + configured_model=self._config.model, + default_provider=default_provider, + default_model=default_model, + auth_results=auth_results, + model_provider_resolver=self.models.provider_for, + ) + async def handle_message( self, key: SessionKey, @@ -748,6 +768,8 @@ async def _resolve_degraded_fallback( health = self._bootstrap_health if health is None or health.is_ready: return _FallbackDecision(allowed=True, surface=surface) + if normalize_provider_name(self._config.provider) != normalize_provider_name(health.default_provider): + return _FallbackDecision(allowed=True, surface=surface) if health.status != ReadinessStatus.DEGRADED or not health.fallback_provider or not health.fallback_model: return None action = self._config.provider_fallback.action_for_surface(surface) diff --git a/controlmesh/orchestrator/providers.py b/controlmesh/orchestrator/providers.py index 6d1370a..da2429c 100644 --- a/controlmesh/orchestrator/providers.py +++ b/controlmesh/orchestrator/providers.py @@ -171,6 +171,12 @@ def apply_auth_results( self._provider_availability = availability cli_service.update_available_providers(self._available_providers) + def auth_results_snapshot(self) -> dict[str, AuthResult]: + """Return a fresh auth snapshot for readiness recomputation.""" + from controlmesh.cli.auth import check_all_auth + + return check_all_auth() + def init_gemini_state(self, paths_workspace: object) -> None: """Cache Gemini API-key mode and trust workspace once at startup.""" from controlmesh.cli.auth import gemini_uses_api_key_mode diff --git a/controlmesh/orchestrator/selectors/model_selector.py b/controlmesh/orchestrator/selectors/model_selector.py index 29109b2..9107b63 100644 --- a/controlmesh/orchestrator/selectors/model_selector.py +++ b/controlmesh/orchestrator/selectors/model_selector.py @@ -392,6 +392,7 @@ async def switch_model( updates["reasoning_effort"] = reasoning_effort await update_config_file_async(orch.paths.config_path, **updates) + orch.refresh_bootstrap_health() # Sub-agent: also sync model/provider/effort to agents.json so the # registry stays current and survives restarts without merge hacks. diff --git a/controlmesh/provider_health.py b/controlmesh/provider_health.py index 5d8343b..2b0137b 100644 --- a/controlmesh/provider_health.py +++ b/controlmesh/provider_health.py @@ -470,6 +470,35 @@ def apply_config_migrations(raw: dict[str, object]) -> tuple[dict[str, object], ) ) + permission_mode = raw.get("permission_mode") + root_permission_mode = raw.get("claude_root_permission_mode") + root_force_bypass = raw.get("claude_root_force_bypass_via_is_sandbox") + if ( + permission_mode == "bypassPermissions" + and root_permission_mode == "dontAsk" + and root_force_bypass in (None, False) + ): + merged["claude_root_permission_mode"] = "bypassPermissions" + changed = True + events.append( + ConfigMigrationEvent( + field="claude_root_permission_mode", + before="dontAsk", + after="bypassPermissions", + reason="upgraded legacy Claude root fallback default to highest-permission baseline", + ) + ) + if root_force_bypass is not True: + merged["claude_root_force_bypass_via_is_sandbox"] = True + events.append( + ConfigMigrationEvent( + field="claude_root_force_bypass_via_is_sandbox", + before=str(root_force_bypass), + after="True", + reason="enabled Claude root IS_SANDBOX escape hatch for highest-permission baseline", + ) + ) + return merged, tuple(events), changed diff --git a/controlmesh/session/manager.py b/controlmesh/session/manager.py index 112583f..d6f8106 100644 --- a/controlmesh/session/manager.py +++ b/controlmesh/session/manager.py @@ -392,6 +392,8 @@ async def resolve_session( preserve_existing_target and bool(existing.provider.strip()) and bool(existing.model.strip()) + and existing.provider == prov + and existing.model == model_name ): if self._apply_topic_name(existing): await self._save(sessions) diff --git a/docs/config.md b/docs/config.md index 5d2c817..bdd3e36 100644 --- a/docs/config.md +++ b/docs/config.md @@ -143,7 +143,11 @@ Notes: | `app_secret` | `str` | `""` | Feishu app secret | | `domain` | `str` | `"https://open.feishu.cn"` | Runtime API base URL | | `allow_from` | `list[str]` | `[]` | Optional sender allowlist (Feishu user IDs such as `open_id`) | -| `group_reply_all` | `bool` | `false` | Reserved bot-only group behavior knob for later cuts | +| `allowed_chat_ids` | `list[str]` | `[]` | Optional Feishu group/chat allowlist for `group_policy="allowlist"` | +| `group_policy` | `"open" \| "allowlist" \| "disabled"` | `"disabled"` | Group-chat ingress policy. Default stays fail-closed. | +| `group_message_mode` | `"passive" \| "mention_only" \| "mention_patterns"` | `"mention_only"` | How a group message becomes active after the group itself is allowed. | +| `mention_patterns` | `list[str]` | `[]` | Plain-text activation phrases for `group_message_mode="mention_patterns"` | +| `group_reply_all` | `bool` | `false` | Reserved outbound group behavior knob; ingress activation uses `group_policy` + `group_message_mode` | | `thread_isolation` | `bool` | `false` | When enabled, inbound thread/root IDs get separate ControlMesh session keys | | `reply_to_trigger` | `bool` | `true` | Reply to the triggering Feishu message when possible | | `progress_mode` | `"text" \| "card_preview" \| "card_stream"` | `"text"` | `text` sends plain progress text; `card_preview` repeatedly patches one interactive card; `card_stream` uses Feishu CardKit streaming card APIs and requires `runtime_mode="native"` | @@ -154,6 +158,11 @@ Current implementation status: - bot-only mode is the production runtime path - `bridge` is the compatibility path for manually managed `app_id/app_secret` setups - `native` is the Feishu-first path; `controlmesh auth feishu register-begin` + `register-poll` writes `runtime_mode=native` +- group ingress is a separate policy layer: + - `group_policy="disabled"` keeps ordinary group traffic passive + - `group_policy="allowlist"` requires `allowed_chat_ids` + - standalone slash commands still activate in allowed groups + - `mention_only` expects an explicit bot mention or reply/thread context - `card_stream` uses CardKit create/update/close APIs and falls back to ordinary text if CardKit is unavailable - optional device-flow auth reuses the configured app for user-token flows - see `docs/feishu-setup.md` for the first-time app-bot setup path diff --git a/docs/release-note-v0.33.1.md b/docs/release-note-v0.33.1.md new file mode 100644 index 0000000..9f53215 --- /dev/null +++ b/docs/release-note-v0.33.1.md @@ -0,0 +1,21 @@ +# ControlMesh v0.33.1 + +This patch release fixes the Telegram runtime compatibility regressions exposed by newer `aiogram` builds and removes the hotfix-only state required on affected hosts. + +## Included fixes + +- Accept `timeout=` in the custom Telegram polling session request path. +- Implement `stream_content()` on the custom Telegram polling session. +- Remove the stale Telegram polling watchdog startup hook. +- Serialize inbound Telegram spool entries with Python-mode model data and JSON-safe fallback handling for aiogram default sentinel values. +- Send the first streaming Telegram reply through the explicit bot API path so reply metadata stays valid before `message.answer(...)` is available. + +## Operational impact + +- Hosts that were manually hot-patched can now be reinstalled cleanly from this release line. +- `meiren` and `qiaopai` no longer need runtime-edited Telegram files once upgraded to a build containing `v0.33.1`. + +## Release notes + +- `pyproject.toml` and `controlmesh/__init__.py` are aligned to `0.33.1`. +- Release this version with tag `v0.33.1`. diff --git a/docs/release-note-v0.33.2.md b/docs/release-note-v0.33.2.md new file mode 100644 index 0000000..78186e5 --- /dev/null +++ b/docs/release-note-v0.33.2.md @@ -0,0 +1,18 @@ +# ControlMesh v0.33.2 + +This patch release removes an operator-hostile OpenCode preflight failure mode and keeps the Telegram/Feishu runtime usable when an OpenCode model probe is slow or temporarily unrunnable. + +## Included fixes + +- OpenCode explicit model preflight no longer aborts the whole turn when `opencode run` probe times out or fails. +- ControlMesh now keeps the configured OpenCode model and lets the real runtime execution decide the outcome, instead of surfacing a generic internal error before the turn starts. + +## Operational impact + +- Switching `/model` to `opencode` models such as `zhipuai/glm-5.1` no longer turns the next user message into `An internal error occurred` just because the 15-second preflight probe was too strict. +- Provider/model state remains debuggable without forcing operators to roll back from the selected model immediately. + +## Release notes + +- This patch is intended to follow `v0.33.1`. +- Release this version with tag `v0.33.2`. diff --git a/pyproject.toml b/pyproject.toml index 1943a9f..e729bd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "controlmesh" -version = "0.33.0" +version = "0.33.2" description = "ControlMesh brings official coding CLIs into Telegram, Matrix, and Feishu with persistent workspaces, long-running jobs, and production-friendly bot operations." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/cli/test_service_extended.py b/tests/cli/test_service_extended.py index 159bbeb..28b2713 100644 --- a/tests/cli/test_service_extended.py +++ b/tests/cli/test_service_extended.py @@ -191,22 +191,20 @@ def test_resolve_provider_errors_when_opencode_model_unresolved(tmp_path: Path) raise AssertionError("expected ValueError") -def test_resolve_provider_errors_when_explicit_opencode_model_is_unrunnable(tmp_path: Path) -> None: +def test_resolve_provider_keeps_explicit_opencode_model_when_probe_fails(tmp_path: Path) -> None: svc = _make_service(tmp_path) with patch("controlmesh.cli.service.probe_opencode_model_sync", return_value=False): - try: - svc.resolve_provider( - AgentRequest( - prompt="test", - provider_override="opencode", - model_override="openai/gpt-4.1", - chat_id=1, - ) + provider, model = svc.resolve_provider( + AgentRequest( + prompt="test", + provider_override="opencode", + model_override="openai/gpt-4.1", + chat_id=1, ) - except ValueError as exc: - assert str(exc) == "error:opencode_model_unrunnable model=openai/gpt-4.1" - else: - raise AssertionError("expected ValueError") + ) + + assert provider == "opencode" + assert model == "openai/gpt-4.1" def test_make_cli_with_openai_agents_provider_override(tmp_path: Path) -> None: diff --git a/tests/messenger/feishu/test_bot.py b/tests/messenger/feishu/test_bot.py index f1ca48b..da3fac8 100644 --- a/tests/messenger/feishu/test_bot.py +++ b/tests/messenger/feishu/test_bot.py @@ -147,9 +147,159 @@ async def test_handle_incoming_event_normalizes_text_payload(self, tmp_path: Pat text="hello from feishu", thread_id="omt_1", create_time_ms=create_time_ms, + chat_type=None, + mentions=(), ) ) + async def test_handle_incoming_event_keeps_group_message_passive_by_default( + self, + tmp_path: Path, + ) -> None: + bot = _make_bot(tmp_path) + bot.handle_incoming_text = AsyncMock() # type: ignore[method-assign] + + payload = { + "schema": "2.0", + "header": { + "event_id": "evt_group_1", + "event_type": "im.message.receive_v1", + "create_time": str(int(time.time() * 1000)), + }, + "event": { + "sender": {"sender_id": {"open_id": "ou_sender"}}, + "message": { + "message_id": "om_group_1", + "chat_id": "oc_group_1", + "chat_type": "group", + "message_type": "text", + "content": '{"text":"plain group traffic"}', + }, + }, + } + + await bot.handle_incoming_event(payload) + + bot.handle_incoming_text.assert_not_awaited() + + async def test_handle_incoming_event_activates_group_message_via_slash_command( + self, + tmp_path: Path, + ) -> None: + bot = _make_bot(tmp_path, group_policy="allowlist", allowed_chat_ids=["oc_group_1"]) + bot.handle_incoming_text = AsyncMock() # type: ignore[method-assign] + create_time_ms = int(time.time() * 1000) + + payload = { + "schema": "2.0", + "header": { + "event_id": "evt_group_2", + "event_type": "im.message.receive_v1", + "create_time": str(create_time_ms), + }, + "event": { + "sender": {"sender_id": {"open_id": "ou_sender"}}, + "message": { + "message_id": "om_group_2", + "chat_id": "oc_group_1", + "chat_type": "group", + "message_type": "text", + "content": '{"text":" /status "}', + }, + }, + } + + await bot.handle_incoming_event(payload) + + bot.handle_incoming_text.assert_awaited_once_with( + FeishuIncomingText( + sender_id="ou_sender", + chat_id="oc_group_1", + message_id="om_group_2", + text="/status", + create_time_ms=create_time_ms, + chat_type="group", + mentions=(), + ) + ) + + async def test_handle_incoming_event_activates_group_message_via_mention_only( + self, + tmp_path: Path, + ) -> None: + bot = _make_bot( + tmp_path, + group_policy="allowlist", + allowed_chat_ids=["oc_group_1"], + group_message_mode="mention_only", + ) + bot.handle_incoming_text = AsyncMock() # type: ignore[method-assign] + create_time_ms = int(time.time() * 1000) + + payload = { + "schema": "2.0", + "header": { + "event_id": "evt_group_3", + "event_type": "im.message.receive_v1", + "create_time": str(create_time_ms), + }, + "event": { + "sender": {"sender_id": {"open_id": "ou_sender"}}, + "message": { + "message_id": "om_group_3", + "chat_id": "oc_group_1", + "chat_type": "group", + "message_type": "text", + "mentions": [{"key": "@_user_1", "id": {"open_id": "ou_bot"}}], + "content": '{"text":"@_user_1 帮我看下"}', + }, + }, + } + + await bot.handle_incoming_event(payload) + + bot.handle_incoming_text.assert_awaited_once() + message = bot.handle_incoming_text.await_args.args[0] + assert message.chat_type == "group" + assert message.mentions == ("ou_bot",) + assert message.text == "帮我看下" + + async def test_handle_incoming_event_activates_group_message_via_mention_pattern( + self, + tmp_path: Path, + ) -> None: + bot = _make_bot( + tmp_path, + group_policy="allowlist", + allowed_chat_ids=["oc_group_1"], + group_message_mode="mention_patterns", + mention_patterns=["清梦", "ControlMesh"], + ) + bot.handle_incoming_text = AsyncMock() # type: ignore[method-assign] + + payload = { + "schema": "2.0", + "header": { + "event_id": "evt_group_4", + "event_type": "im.message.receive_v1", + "create_time": str(int(time.time() * 1000)), + }, + "event": { + "sender": {"sender_id": {"open_id": "ou_sender"}}, + "message": { + "message_id": "om_group_4", + "chat_id": "oc_group_1", + "chat_type": "group", + "message_type": "text", + "content": '{"text":"清梦 帮我继续"}', + }, + }, + } + + await bot.handle_incoming_event(payload) + + bot.handle_incoming_text.assert_awaited_once() + async def test_handle_incoming_event_extracts_post_and_reply_thread_context( self, tmp_path: Path, diff --git a/tests/messenger/telegram/test_app.py b/tests/messenger/telegram/test_app.py index 1e4fbd8..3f735ca 100644 --- a/tests/messenger/telegram/test_app.py +++ b/tests/messenger/telegram/test_app.py @@ -204,6 +204,66 @@ def test_orch_property_raises_before_startup(self) -> None: _ = tg_bot._orch +class TestTelegramPollingSession: + async def test_make_request_forwards_timeout_keyword(self) -> None: + from controlmesh.messenger.telegram.app import _TelegramPollingSession + + inner = MagicMock() + inner.api = MagicMock() + inner.json_loads = MagicMock() + inner.json_dumps = MagicMock() + inner.timeout = 30 + inner.middleware = MagicMock() + inner.make_request = AsyncMock(return_value=[]) + + session = _TelegramPollingSession( + inner, + on_poll_started=MagicMock(), + on_poll_succeeded=MagicMock(), + on_poll_failed=AsyncMock(), + ) + bot = MagicMock() + method = GetUpdates(offset=1, timeout=10) + + result = await session.make_request(bot, method, timeout=55) + + assert result == [] + inner.make_request.assert_awaited_once_with(bot, method, timeout=55) + + async def test_stream_content_delegates_to_inner_session(self) -> None: + from controlmesh.messenger.telegram.app import _TelegramPollingSession + + async def _fake_stream(): + yield b"a" + yield b"b" + + inner = MagicMock() + inner.api = MagicMock() + inner.json_loads = MagicMock() + inner.json_dumps = MagicMock() + inner.timeout = 30 + inner.middleware = MagicMock() + inner.stream_content = MagicMock(return_value=_fake_stream()) + + session = _TelegramPollingSession( + inner, + on_poll_started=MagicMock(), + on_poll_succeeded=MagicMock(), + on_poll_failed=AsyncMock(), + ) + + chunks = [chunk async for chunk in session.stream_content("https://example.com", timeout=5)] + + assert chunks == [b"a", b"b"] + inner.stream_content.assert_called_once_with( + "https://example.com", + headers=None, + timeout=5, + chunk_size=65536, + raise_for_status=True, + ) + + class TestTelegramBotRun: async def test_run_returns_exit_code(self) -> None: tg_bot, bot_instance = _make_tg_bot() @@ -1643,6 +1703,40 @@ async def test_on_message_enqueues_into_spool_and_acknowledges_after_run(self, t assert tg_bot._inbound_spool is not None assert tg_bot._inbound_spool.stats().pending_count == 0 + async def test_on_message_spool_uses_python_dump_for_aiogram_defaults(self, tmp_path: Path) -> None: + class _DefaultLike: + pass + + config = _make_config(streaming_enabled=False) + config.controlmesh_home = str(tmp_path) + tg_bot, _bot_instance = _make_tg_bot(config) + tg_bot._orchestrator = _make_orchestrator(handle_message_text="queued reply") + tg_bot._bot_id = 999 + tg_bot._bot_username = "controlmesh_bot" + tg_bot._configure_inbound_spool() + msg = _make_message(text="Hello bot", user_id=200) + msg.model_dump = MagicMock( + return_value={ + "message_id": 10, + "date": 1710000000, + "chat": {"id": 1, "type": "private"}, + "from": {"id": 200, "is_bot": False, "first_name": "TestUser"}, + "text": "Hello bot", + "weird_default": _DefaultLike(), + } + ) + + with patch( + "controlmesh.messenger.telegram.app.run_non_streaming_message", new_callable=AsyncMock + ) as mock_run: + await tg_bot._on_message(msg) + await _wait_frontstage_idle(tg_bot) + + msg.model_dump.assert_called_once_with(mode="python", exclude_none=True) + mock_run.assert_awaited_once() + assert tg_bot._inbound_spool is not None + assert tg_bot._inbound_spool.stats().pending_count == 0 + async def test_recover_inbound_spool_replays_pending_message_on_startup(self, tmp_path: Path) -> None: config = _make_config(streaming_enabled=False) config.controlmesh_home = str(tmp_path) diff --git a/tests/messenger/telegram/test_edit_streaming.py b/tests/messenger/telegram/test_edit_streaming.py index 62b6ef2..5ef85c8 100644 --- a/tests/messenger/telegram/test_edit_streaming.py +++ b/tests/messenger/telegram/test_edit_streaming.py @@ -426,6 +426,18 @@ async def test_thread_id_none_by_default(self) -> None: await editor.finalize("") assert bot.send_message.call_args.kwargs.get("message_thread_id") is None + async def test_reply_path_omits_thread_id_when_not_set(self) -> None: + reply_msg = MagicMock(spec=Message) + object.__setattr__(reply_msg, "message_id", 99) + + bot, editor = _make_editor(reply_to=reply_msg) + await editor.append_text("Hello") + await editor.finalize("") + + bot.send_message.assert_called_once() + assert bot.send_message.call_args.kwargs["reply_parameters"].message_id == 99 + assert "message_thread_id" not in bot.send_message.call_args.kwargs + async def test_thread_id_on_fallback_send_new(self) -> None: bot, editor = _make_editor(max_failures=1, thread_id=55) await editor.append_text("Initial") diff --git a/tests/messenger/telegram/test_startup_watchdog_compat.py b/tests/messenger/telegram/test_startup_watchdog_compat.py new file mode 100644 index 0000000..eb17bd1 --- /dev/null +++ b/tests/messenger/telegram/test_startup_watchdog_compat.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + + +class TestTelegramStartupWatchdogCompat: + async def test_run_startup_uses_current_watchdog_fields(self) -> None: + from controlmesh.messenger.telegram import startup + + bot = MagicMock() + bot._orchestrator = MagicMock() + bot.bot_instance.get_me = AsyncMock(return_value=MagicMock(id=123, username="cm_bot")) + bot._sync_commands = AsyncMock() + bot._watch_restart_marker = AsyncMock() + bot.audit_groups = AsyncMock() + bot._run_group_audit_loop = AsyncMock() + bot._watch_poll_health = AsyncMock() + + created = [] + + def _fake_create_task(coro, *, name=None): + coro.close() + task = MagicMock() + task.coro = coro + task.name = name + created.append(task) + return task + + with patch("controlmesh.messenger.telegram.startup.asyncio.create_task", side_effect=_fake_create_task): + await startup.run_startup(bot) + + assert bot._bot_id == 123 + assert bot._bot_username == "cm_bot" + bot._sync_commands.assert_awaited_once() + bot.audit_groups.assert_awaited_once() + assert len(created) == 2 + assert bot._restart_watcher is created[0] + assert bot._group_audit_task is created[1] diff --git a/tests/session/test_manager.py b/tests/session/test_manager.py index e0282e9..45b2124 100644 --- a/tests/session/test_manager.py +++ b/tests/session/test_manager.py @@ -271,6 +271,25 @@ async def test_model_update_without_provider_switch(tmp_path: Path) -> None: assert s2.model == "gpt-5.2-codex" +async def test_preserve_existing_target_only_keeps_matching_provider_and_model( + tmp_path: Path, +) -> None: + mgr = _make_manager(tmp_path) + s1, _ = await mgr.resolve_session(key=SessionKey(chat_id=1), provider="claude", model="sonnet") + await _simulate_cli_response(mgr, s1, "claude-session-id") + + s2, is_new = await mgr.resolve_session( + key=SessionKey(chat_id=1), + provider="codex", + model="gpt-5.5", + preserve_existing_target=True, + ) + assert is_new is False + assert s2.provider == "codex" + assert s2.model == "gpt-5.5" + assert s2.session_id == "" + + async def test_legacy_session_without_model_is_migrated_on_resolve(tmp_path: Path) -> None: path = tmp_path / "sessions.json" path.write_text( diff --git a/tests/test_config.py b/tests/test_config.py index 5b72a85..26af3bb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -26,6 +26,7 @@ GatewayEventRuleConfig, GatewayTargetConfig, ) +from controlmesh.provider_health import apply_config_migrations from controlmesh.team.contracts import TEAM_TOPOLOGIES # -- AgentConfig defaults -- @@ -136,6 +137,52 @@ def test_deep_merge_no_change() -> None: assert changed is False +def test_apply_config_migrations_upgrades_legacy_claude_root_defaults() -> None: + raw = { + "permission_mode": "bypassPermissions", + "claude_root_permission_mode": "dontAsk", + "claude_root_force_bypass_via_is_sandbox": False, + } + + migrated, events, changed = apply_config_migrations(raw) + + assert changed is True + assert migrated["claude_root_permission_mode"] == "bypassPermissions" + assert migrated["claude_root_force_bypass_via_is_sandbox"] is True + assert [event.field for event in events] == [ + "claude_root_permission_mode", + "claude_root_force_bypass_via_is_sandbox", + ] + + +def test_apply_config_migrations_upgrades_missing_legacy_claude_root_escape_hatch() -> None: + raw = { + "permission_mode": "bypassPermissions", + "claude_root_permission_mode": "dontAsk", + } + + migrated, events, changed = apply_config_migrations(raw) + + assert changed is True + assert migrated["claude_root_permission_mode"] == "bypassPermissions" + assert migrated["claude_root_force_bypass_via_is_sandbox"] is True + assert len(events) == 2 + + +def test_apply_config_migrations_preserves_explicit_claude_root_override() -> None: + raw = { + "permission_mode": "bypassPermissions", + "claude_root_permission_mode": "plan", + "claude_root_force_bypass_via_is_sandbox": False, + } + + migrated, events, changed = apply_config_migrations(raw) + + assert changed is False + assert migrated == raw + assert events == () + + # -- ModelRegistry -- @@ -231,6 +278,25 @@ def test_feishu_card_stream_requires_native_runtime_mode() -> None: ) +def test_feishu_group_config_fields() -> None: + cfg = AgentConfig( + transport="feishu", + feishu={ + "app_id": "cli_123", + "app_secret": "secret", + "allowed_chat_ids": ["oc_group_1"], + "group_policy": "allowlist", + "group_message_mode": "mention_patterns", + "mention_patterns": ["清梦", "ControlMesh"], + }, + ) + + assert cfg.feishu.allowed_chat_ids == ["oc_group_1"] + assert cfg.feishu.group_policy == "allowlist" + assert cfg.feishu.group_message_mode == "mention_patterns" + assert cfg.feishu.mention_patterns == ["清梦", "ControlMesh"] + + @pytest.mark.parametrize("topology", TEAM_TOPOLOGIES) def test_tasks_default_topology_accepts_approved_values(topology: str) -> None: cfg = AgentConfig(tasks={"default_topology": topology})