Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/coding_agent_telegram/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ def build_application(token: str, router: CommandRouter, *, allowed_chat_ids: se
app.add_handler(CommandHandler("commit", router.handle_commit, filters=allowed_private))
app.add_handler(CommandHandler("push", router.handle_push, filters=allowed_private))
app.add_handler(CallbackQueryHandler(router.handle_provider_callback, pattern=r"^provider:set:(codex|copilot)$", block=False))
app.add_handler(CallbackQueryHandler(router.handle_queue_batch_callback, pattern=r"^queuebatch:(group|single)$", block=False))
app.add_handler(CallbackQueryHandler(router.handle_queue_continue_callback, pattern=r"^queuecontinue:(yes|no)$", block=False))
app.add_handler(CallbackQueryHandler(router.handle_branch_source_callback, pattern=r"^branchsource:(local|origin):", block=False))
app.add_handler(CallbackQueryHandler(router.handle_branch_discrepancy_callback, pattern=r"^branchdiscrepancy:(stored|current)$", block=False))
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/command_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from coding_agent_telegram.router.git_commands import GitCommandMixin
from coding_agent_telegram.router.message_commands import MessageCommandMixin
from coding_agent_telegram.router.project_commands import ProjectCommandMixin
from coding_agent_telegram.router.queue_processing import QueueProcessingMixin
from coding_agent_telegram.router.session_commands import SessionCommandMixin
from coding_agent_telegram.router.switch_commands import SwitchCommandMixin

Expand All @@ -13,6 +14,7 @@ class CommandRouter(
GitCommandMixin,
SwitchCommandMixin,
SessionCommandMixin,
QueueProcessingMixin,
MessageCommandMixin,
CommandRouterBase,
):
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class AppConfig:
snapshot_text_file_max_bytes: int
max_telegram_message_length: int
enable_sensitive_diff_filter: bool
enable_secret_scrub_filter: bool
default_agent_provider: str
agent_hard_timeout_seconds: int
app_internal_root: Path
Expand Down Expand Up @@ -173,6 +174,7 @@ def load_config(env_file: Optional[Path] = None) -> AppConfig:
os.getenv("MAX_TELEGRAM_MESSAGE_LENGTH", str(DEFAULT_MAX_TELEGRAM_MESSAGE_LENGTH))
),
enable_sensitive_diff_filter=_parse_bool(os.getenv("ENABLE_SENSITIVE_DIFF_FILTER", "true"), default=True),
enable_secret_scrub_filter=_parse_bool(os.getenv("ENABLE_SECRET_SCRUB_FILTER", "true"), default=True),
default_agent_provider=provider,
agent_hard_timeout_seconds=int(
os.getenv("AGENT_HARD_TIMEOUT_SECONDS", str(DEFAULT_AGENT_HARD_TIMEOUT_SECONDS))
Expand Down
4 changes: 4 additions & 0 deletions src/coding_agent_telegram/resources/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ ENABLE_GROUP_CHATS=false
# If true, hide diffs for sensitive-looking file paths.
ENABLE_SENSITIVE_DIFF_FILTER=true

# If true, redact recognized secrets and secret-like blobs from assistant output before sending them to Telegram.
# Strongly recommended: keep this set to true.
ENABLE_SECRET_SCRUB_FILTER=true

# Default agent provider for new sessions: codex or copilot.
DEFAULT_AGENT_PROVIDER=codex

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# name=regex ; replacement is derived automatically as <name>
telegram-token=\b\d{9,10}:[A-Za-z0-9_-]{30,}\b
github-token=\bgh[pousr]_[A-Za-z0-9]{36,}\b
openai-project-key=\bsk-proj-[A-Za-z0-9_-]{20,}\b
openai-key=\bsk-(?!proj-)(?!ant-)[A-Za-z0-9_-]{20,}\b
anthropic-key=\bsk-ant-[A-Za-z0-9_-]{20,}\b
gitlab-token=\bglpat-[A-Za-z0-9\-_]{20,}\b
slack-token=\bxox[baprs]-[A-Za-z0-9\-]{10,}\b
stripe-secret-key=\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b
gcp-api-key=\bAIza[A-Za-z0-9\-_]{35}\b
aws-access-key=\bAKIA[A-Z0-9]{16}\b
jwt-like-token=\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b
ssh-public-key=(?m)^(?:ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-nistp(?:256|384|521)) [A-Za-z0-9+/=]+(?: [^\r\n]+)?$
passwd-like-line=(?m)^[a-z_][a-z0-9_-]{0,31}:[x*]?:\d+:\d+:[^:\r\n]*:(?:/[^:\r\n]*)?:(?:/[^:\r\n]+|[A-Za-z0-9_./-]+)$
shadow-like-line=(?m)^[a-z_][a-z0-9_-]{0,31}:\$[^:\r\n]{10,}:[0-9:]{5,}.*$
crt-like-text=(?s)-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----
pem-like-text=(?s)-----BEGIN [A-Z0-9_ -]+-----.*?-----END [A-Z0-9_ -]+-----
hex-like-text=\b(?:[A-Fa-f0-9]{32,})\b
base64-like-text=\b(?:[A-Za-z0-9+/]{48,}={0,2})\b
184 changes: 1 addition & 183 deletions src/coding_agent_telegram/router/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from dataclasses import dataclass
from functools import wraps
from pathlib import Path
from types import SimpleNamespace
from typing import Awaitable, Callable, Optional, Tuple

from telegram import Update
Expand All @@ -32,7 +31,6 @@
TYPING_REFRESH_TIMEOUT_SECONDS = 4
ACTIVE_SESSION_REQUIRED_MESSAGE = "No active session.\nPlease run /project and /new first."
PROGRESS_PREVIEW_MAX_CHARS = 600
QUEUED_QUESTIONS_DIR = "queued_questions"


def require_allowed_chat(*, answer_callback: bool = False):
Expand Down Expand Up @@ -125,6 +123,7 @@ def __init__(self, deps: RouterDeps) -> None:
self._workspace_locks: dict[str, asyncio.Lock] = {}
self._chat_message_queue_files: dict[int, deque[Path]] = {}
self._chat_processing_queue_files: dict[int, Path] = {}
self._chat_pending_queue_decisions: dict[int, tuple[Path, list[str]]] = {}
self._chat_next_queue_file_index: dict[int, int] = {}
self._chat_message_queue_draining: set[int] = set()
self._last_run_results: dict[int, object] = {}
Expand Down Expand Up @@ -199,187 +198,6 @@ def _is_project_busy(self, chat_id: int) -> bool:
has_running_process = getattr(self.deps.agent_runner, "has_running_process", None)
return bool(has_running_process is not None and has_running_process(project_path))

def _queue_dir(self, chat_id: int) -> Path:
queue_dir = self.deps.cfg.app_internal_root / QUEUED_QUESTIONS_DIR / str(chat_id)
queue_dir.mkdir(parents=True, exist_ok=True)
return queue_dir

def _queue_lock_path(self, queue_file: Path) -> Path:
return queue_file.with_suffix(queue_file.suffix + ".lock")

def _sanitize_queue_session_id(self, session_id: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", session_id.strip())
return cleaned or "session"

def _next_queue_file_path(self, chat_id: int) -> Path:
queue_dir = self._queue_dir(chat_id)
if chat_id not in self._chat_message_queue_files and chat_id not in self._chat_processing_queue_files:
next_index = 0
else:
next_index = self._chat_next_queue_file_index.get(chat_id, -1) + 1
self._chat_next_queue_file_index[chat_id] = next_index
chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id)
session_id = self._sanitize_queue_session_id(str(chat_state.get("active_session_id") or "session"))
return queue_dir / f"{session_id}-queue-{next_index}.txt"

def _read_queue_questions(self, queue_file: Path) -> list[str]:
if not queue_file.exists():
return []
raw = queue_file.read_text(encoding="utf-8")
pattern = re.compile(r"^\[Question (\d+)\]\n(.*?)\n\[End Question \1\]\s*$", re.MULTILINE | re.DOTALL)
return [match.group(2).strip() for match in pattern.finditer(raw) if match.group(2).strip()]

def _append_question_to_queue_file(self, queue_file: Path, user_message: str) -> int:
questions = self._read_queue_questions(queue_file)
next_number = len(questions) + 1
with queue_file.open("a", encoding="utf-8") as fh:
if queue_file.stat().st_size > 0:
fh.write("\n")
fh.write(f"[Question {next_number}]\n{user_message.strip()}\n[End Question {next_number}]\n")
return next_number

def _enqueue_chat_message(self, chat_id: int, user_message: str) -> tuple[Path, int]:
queue = self._chat_message_queue_files.setdefault(chat_id, deque())
queue_file = queue[-1] if queue else self._next_queue_file_path(chat_id)
if not queue:
queue.append(queue_file)
question_number = self._append_question_to_queue_file(queue_file, user_message)
return queue_file, question_number

def _dequeue_chat_message_file(self, chat_id: int) -> tuple[Path | None, list[str]]:
queue = self._chat_message_queue_files.get(chat_id)
if not queue:
return None, []
queue_file = queue.popleft()
questions = self._read_queue_questions(queue_file)
if not questions:
if not queue:
self._chat_message_queue_files.pop(chat_id, None)
return None, []
if not queue:
self._chat_message_queue_files.pop(chat_id, None)
return queue_file, questions

def _queued_file_prompt(self, queue_file: Path) -> str:
return (
"Queued-question handoff. Do not answer this handoff message itself.\n\n"
"The real queued user questions are stored in this file:\n"
f"{queue_file.resolve()}\n\n"
"Read the file first, then answer the questions from the file in order.\n"
"The file uses this format:\n"
"[Question 1]\n"
"...\n"
"[End Question 1]\n\n"
"Do not answer about the file path itself unless one of the queued questions asks about it."
)

def _preview_queued_message(self, message: str, *, max_chars: int = 100) -> str:
stripped = " ".join(message.split())
if len(stripped) <= max_chars:
return stripped
if max_chars <= 3:
return stripped[:max_chars]
return f"{stripped[: max_chars - 3]}..."

def _queued_batch_notice(self, queued_messages: list[str]) -> str:
lines = ["Working on queued questions:"]
for index, message in enumerate(queued_messages, start=1):
lines.append(f"{index}. {self._preview_queued_message(message)}")
return "\n".join(lines)

def _run_result_was_aborted(self, result: object) -> bool:
error_message = getattr(result, "error_message", None)
return isinstance(error_message, str) and error_message.strip() == "Agent run aborted by /abort."

def _has_pending_queue_files(self, chat_id: int) -> bool:
queue = self._chat_message_queue_files.get(chat_id)
return bool(queue)

async def _prompt_continue_queued_questions(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None:
if not hasattr(context.bot, "send_message"):
return
from telegram import InlineKeyboardButton, InlineKeyboardMarkup

await context.bot.send_message(
chat_id=chat_id,
text="The previous run was aborted. Do you want to continue processing the pending queued questions?",
reply_markup=InlineKeyboardMarkup(
[[
InlineKeyboardButton("Yes", callback_data="queuecontinue:yes"),
InlineKeyboardButton("No", callback_data="queuecontinue:no"),
]]
),
)

def _clear_chat_message_queue(self, chat_id: int) -> None:
queue = self._chat_message_queue_files.pop(chat_id, deque())
for queue_file in queue:
queue_file.unlink(missing_ok=True)
self._queue_lock_path(queue_file).unlink(missing_ok=True)
processing_file = self._chat_processing_queue_files.pop(chat_id, None)
if processing_file is not None:
processing_file.unlink(missing_ok=True)
self._queue_lock_path(processing_file).unlink(missing_ok=True)
self._chat_next_queue_file_index.pop(chat_id, None)

async def _drain_chat_message_queue(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None:
if chat_id in self._chat_message_queue_draining:
return
self._chat_message_queue_draining.add(chat_id)
try:
while True:
if self._is_project_busy(chat_id):
return
last_result = self._last_run_results.pop(chat_id, None)
if self._run_result_was_aborted(last_result) and self._has_pending_queue_files(chat_id):
processing_file = self._chat_processing_queue_files.get(chat_id)
if processing_file is not None:
processing_file.unlink(missing_ok=True)
self._queue_lock_path(processing_file).unlink(missing_ok=True)
self._chat_processing_queue_files.pop(chat_id, None)
await self._prompt_continue_queued_questions(chat_id, context)
return
processing_file = self._chat_processing_queue_files.get(chat_id)
if processing_file is not None:
processing_file.unlink(missing_ok=True)
self._queue_lock_path(processing_file).unlink(missing_ok=True)
self._chat_processing_queue_files.pop(chat_id, None)
queue_file, queued_messages = self._dequeue_chat_message_file(chat_id)
if queue_file is None or not queued_messages:
if chat_id not in self._chat_processing_queue_files and chat_id not in self._chat_message_queue_files:
self._chat_next_queue_file_index.pop(chat_id, None)
return
self._chat_processing_queue_files[chat_id] = queue_file
self._queue_lock_path(queue_file).write_text("", encoding="utf-8")
queued_notice = self._queued_batch_notice(queued_messages)
queued_update = SimpleNamespace(
effective_chat=SimpleNamespace(id=chat_id, type="private"),
message=SimpleNamespace(text=queued_notice, photo=None, caption=None),
)
await send_text(queued_update, context, queued_notice)
combined_message = self._queued_file_prompt(queue_file)
queued_update = SimpleNamespace(
effective_chat=SimpleNamespace(id=chat_id, type="private"),
message=SimpleNamespace(text=combined_message, photo=None, caption=None),
)
self.deps.store.set_pending_action(
self.deps.bot_id,
chat_id,
{
"kind": "message",
"user_message": combined_message,
},
)
continued = await self._continue_pending_action(queued_update, context)
if not continued:
self._queue_lock_path(queue_file).unlink(missing_ok=True)
self._chat_processing_queue_files.pop(chat_id, None)
queue = self._chat_message_queue_files.setdefault(chat_id, deque())
queue.appendleft(queue_file)
return
finally:
self._chat_message_queue_draining.discard(chat_id)

async def _notify_if_current_project_busy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
chat = update.effective_chat
if chat is None:
Expand Down
2 changes: 1 addition & 1 deletion src/coding_agent_telegram/router/message_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYP
return
user_message = update.message.text
chat_id = update.effective_chat.id
if self._is_project_busy(chat_id):
if self._is_project_busy(chat_id) or self._has_pending_queue_decision(chat_id):
_queue_file, question_number = self._enqueue_chat_message(chat_id, user_message)
await send_text(
update,
Expand Down
Loading
Loading