feat: add group/channel support for Telegram bot

- Add TELEGRAM_GROUP_IDS configuration setting to enable optional group/channel filtering
- Add _is_group_enabled() authorization check to all command handlers
- Add group/channel authorization check to message_handler
- All handlers now verify both user authorization and group authorization
- If TELEGRAM_GROUP_IDS is not configured, bot accepts messages from any group
- If TELEGRAM_GROUP_IDS is configured, bot only processes messages from those groups

This enables the bot to be used in group chats and channels with Topics/Threads support.
This commit is contained in:
Daniel Wagner 2026-07-26 12:26:57 +10:00
parent a3ca696546
commit 669b57a317
3 changed files with 42 additions and 6 deletions

View File

@ -1,10 +1,10 @@
services:
steward:
image: ghcr.io/djw4/steward:latest
# image: ghcr.io/djw4/steward:latest
# To build locally instead: uncomment the next line and comment out image above
# build: .
user: "1000:1000" # matches the UID/GID created in the Dockerfile
env_file: .env # copy .env.example → .env and fill in your values
build: .
user: "1000:1000" # matches the UID/GID created in the Dockerfile
env_file: .env # copy .env.example → .env and fill in your values
environment:
THREAD_MEMORY_PATH: /data/thread_memory.json
volumes:

View File

@ -75,6 +75,13 @@ def _is_allowed(user_id: int, settings: Settings) -> bool:
return user_id in settings.telegram_allowed_user_ids
def _is_group_enabled(chat_id: int, settings: Settings) -> bool:
"""Return True if the group/channel is in the configured list (or no list is configured)."""
if not settings.telegram_group_ids:
return True
return chat_id in settings.telegram_group_ids
async def _send_long(update: Update, text: str) -> None:
"""Send text, splitting across messages if it exceeds Telegram's 4096-char limit."""
limit = 4096
@ -92,6 +99,10 @@ async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
if user is None or not _is_allowed(user.id, settings):
return
chat = update.effective_chat
if chat is None or not _is_group_enabled(chat.id, settings):
return
await update.message.reply_text( # type: ignore[union-attr]
"Hello, I'm *Steward* \U0001f916\n\n"
"I'm your AI-assisted personal operations platform.\n"
@ -112,6 +123,10 @@ async def help_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
if user is None or not _is_allowed(user.id, settings):
return
chat = update.effective_chat
if chat is None or not _is_group_enabled(chat.id, settings):
return
await update.message.reply_text( # type: ignore[union-attr]
"*Steward commands*\n\n"
"/start – greeting\n"
@ -136,6 +151,10 @@ async def clear_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
if user is None or not _is_allowed(user.id, settings):
return
chat = update.effective_chat
if chat is None or not _is_group_enabled(chat.id, settings):
return
key = _thread_key(update)
if key is not None:
_thread_history[key].clear()
@ -167,6 +186,10 @@ async def flush_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
if user is None or not _is_allowed(user.id, settings):
return
chat = update.effective_chat
if chat is None or not _is_group_enabled(chat.id, settings):
return
key = _thread_key(update)
if key is None:
await update.message.reply_text( # type: ignore[union-attr]
@ -246,6 +269,10 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
if user is None or not _is_allowed(user.id, settings):
return
chat = update.effective_chat
if chat is None or not _is_group_enabled(chat.id, settings):
return
# If the user supplied a keyword query, search the knowledge base
args: list[str] = context.args or [] # type: ignore[assignment]
if args:
@ -306,13 +333,16 @@ async def analyse_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
if user is None or not _is_allowed(user.id, settings):
return
chat = update.effective_chat
if chat is None or not _is_group_enabled(chat.id, settings):
return
await update.message.reply_text("Running analysis, please wait...") # type: ignore[union-attr]
generator = ProposalGenerator(settings, llm)
proposal = await generator.run()
if proposal is None:
await update.message.reply_text( # type: ignore[union-attr]
"Analysis could not be completed. "
"Check that `ANALYSIS_TARGET_URL` is configured."
"Analysis could not be completed. Check that `ANALYSIS_TARGET_URL` is configured."
)
return
@ -356,8 +386,11 @@ async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
store: ThreadMemoryStore = context.bot_data["thread_store"]
tool_client: ToolClient | None = context.bot_data.get("tool_client")
user = update.effective_user
chat = update.effective_chat
if user is None or not _is_allowed(user.id, settings):
return
if chat is None or not _is_group_enabled(chat.id, settings):
return
text = update.message.text # type: ignore[union-attr]
if not text:
@ -402,6 +435,8 @@ def build_application(
app.bot_data["llm"] = llm
app.bot_data["thread_store"] = thread_store or ThreadMemoryStore(settings.thread_memory_path)
app.bot_data["tool_client"] = tool_client # None when tools are not configured
logger.info("Configured allowed users: %s", settings.telegram_allowed_user_ids)
logger.info("Configured group IDs: %s", settings.telegram_group_ids)
app.add_handler(CommandHandler("start", start_handler))
app.add_handler(CommandHandler("help", help_handler))

View File

@ -11,6 +11,7 @@ class Settings(BaseSettings):
# Telegram
telegram_bot_token: str = ""
telegram_allowed_user_ids: list[int] = []
telegram_group_ids: list[int] = []
# LLM (OpenAI-compatible)
openai_api_key: str = ""