Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion controlmesh/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ControlMesh public package and CLI entrypoint surface."""

__version__ = "0.33.0"
__version__ = "0.33.2"
14 changes: 7 additions & 7 deletions controlmesh/cli/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions controlmesh/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions controlmesh/messenger/feishu/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion controlmesh/messenger/feishu/message_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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))
27 changes: 23 additions & 4 deletions controlmesh/messenger/telegram/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,20 +232,37 @@ 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:
await self._on_poll_failed(method, exc)
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)
Expand Down Expand Up @@ -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(
Expand Down
53 changes: 34 additions & 19 deletions controlmesh/messenger/telegram/edit_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down
23 changes: 22 additions & 1 deletion controlmesh/messenger/telegram/inbound_spool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
1 change: 0 additions & 1 deletion controlmesh/messenger/telegram/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading