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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,39 @@ and this project adheres to Semantic Calendar Versioning with format YYYY.MM.DD.

## [Unreleased]

### Added
- **Runtime Metrics Tracking**: Bot now tracks and displays runtime statistics
- Messages sent count
- Requests received count
- Errors count
- Number of emojis used in messages
- **Enhanced !cd info Command**: Extended bot info display
- CPU model name (e.g., "Intel i7 4770K")
- Runtime type (binary vs Python interpreter)
- All runtime metrics displayed in both plain text and HTML table format
- **Git Version Detection**: Automatic commit ID appending for git-based deployments
- Format: `x.x.x.x.x-c123456` for git vs `x.x.x.x.x` for releases
- Helps distinguish between release and development versions
- **Enhanced !cd rooms Command**: Improved room listing with send permissions
- Color-coded output (green for can send, red for cannot send)
- Table format with "Send Status" column
- Rooms where bot cannot send are hidden when `-R` (redaction) flag is set
- **Centralized Status Information**: New `get_status_info()` method in bot instance
- Provides unified structure for bot status used by both TUI and commands
- Eliminates duplicate logic between different interfaces

### Changed
- **TUI Status Display**: Harmonized with !cd info command
- Updated to use centralized `get_status_info()` method
- Consistent naming and formatting across TUI and commands
- **Version Display**: All version displays now use full version with commit ID when applicable
- **Room Permission Checking**: Enhanced `can_send_message_in_room()` method
- Now also checks `allowed_rooms` configuration setting
- Combines Matrix power levels with config-based restrictions

### Fixed
- Fixed duplicate return statement in `_gather_matrix_info()` method

## [2025.11.15.5.2.0] - 2025-11-15

### Added
Expand Down
34 changes: 34 additions & 0 deletions chatrixcd/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,37 @@
"""ChatrixCD - Matrix bot for CI/CD automation with Semaphore UI."""

import os
import subprocess

__version__ = "2025.11.15.5.2.0"

def _get_version_with_commit():
"""Get version with git commit if running from git repository.

Returns:
Version string with commit ID appended if running from git,
otherwise just the version string.
"""
try:
# Check if we're in a git repository
git_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.git')
if os.path.exists(git_dir):
# Get the short commit hash
result = subprocess.run(
['git', 'rev-parse', '--short', 'HEAD'],
capture_output=True,
text=True,
timeout=1,
cwd=os.path.dirname(os.path.dirname(__file__))
)
if result.returncode == 0:
commit_id = result.stdout.strip()
return f"{__version__}-c{commit_id}"
except Exception:
# If anything fails, just return the base version
pass

return __version__

# Get the full version (with commit if available)
__version_full__ = _get_version_with_commit()
183 changes: 183 additions & 0 deletions chatrixcd/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
import os
import time
import aiohttp
import re
import platform
import sys
import subprocess
import psutil
from typing import Optional, Dict, Any
from nio import (
AsyncClient,
Expand All @@ -34,6 +39,16 @@

logger = logging.getLogger(__name__)

# Compiled emoji pattern for efficient emoji detection and counting
EMOJI_PATTERN = re.compile("["
"\U0001F600-\U0001F64F" # emoticons

Check warning

Code scanning / CodeQL

Overly permissive regular expression range Medium

Suspicious character range that overlaps with \ufffd-\ufffd in the same character class.

Check warning

Code scanning / CodeQL

Overly permissive regular expression range Medium

Suspicious character range that overlaps with \ufffd-\ufffd in the same character class.

Check warning

Code scanning / CodeQL

Overly permissive regular expression range Medium

Suspicious character range that overlaps with \ufffd-\ufffd in the same character class.

Check warning

Code scanning / CodeQL

Overly permissive regular expression range Medium

Suspicious character range that overlaps with \u2702-\u27b0 in the same character class, and overlaps with \ufffd-\ufffd in the same character class.
"\U0001F300-\U0001F5FF" # symbols & pictographs
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F1E0-\U0001F1FF" # flags (iOS)
"\U00002702-\U000027B0" # dingbats
"\U000024C2-\U0001F251"
"]+", flags=re.UNICODE)


class ChatrixBot:
"""ChatrixCD bot for Matrix with Semaphore UI integration."""
Expand Down Expand Up @@ -105,6 +120,14 @@
# Track whether we've done initial encryption setup after first sync
self._encryption_setup_done = False

# Runtime metrics tracking
self.metrics = {
'messages_sent': 0,
'requests_received': 0,
'errors': 0,
'emojis_used': 0
}

# Setup event callbacks
self.client.add_event_callback(self.message_callback, RoomMessageText)
self.client.add_event_callback(self.invite_callback, InviteMemberEvent)
Expand Down Expand Up @@ -543,6 +566,11 @@

logger.info(f"Message from {event.sender} in {room.display_name}: {event.body}")

# Track requests received (only if it's a command)
command_prefix = self.config.get_bot_config().get('command_prefix', '!cd')
if event.body.strip().startswith(command_prefix):
self.metrics['requests_received'] += 1

# Check if message is a command
await self.command_handler.handle_message(room, event)

Expand Down Expand Up @@ -802,6 +830,12 @@
ignore_unverified_devices=True
)

# Track metrics
self.metrics['messages_sent'] += 1
# Count emojis in the message (counts individual emojis, even in consecutive sequences)
emojis = EMOJI_PATTERN.findall(message)
self.metrics['emojis_used'] += sum(len(match) for match in emojis)

# Return the event_id for potential reactions
if hasattr(response, 'event_id'):
return response.event_id
Expand Down Expand Up @@ -830,6 +864,9 @@
content=content,
ignore_unverified_devices=True
)

# Track emoji usage
self.metrics['emojis_used'] += 1
except Exception as e:
logger.debug(f"Failed to send reaction: {e}")

Expand Down Expand Up @@ -879,6 +916,152 @@
except Exception as e:
logger.error(f"Failed to send shutdown message to room {room_id}: {e}")

def can_send_message_in_room(self, room_id: str) -> bool:
"""Check if bot can send messages in a room.

Checks both Matrix power levels and config allowed_rooms setting.

Args:
room_id: Room ID to check

Returns:
True if bot can send messages, False otherwise
"""
# Check if room is in allowed_rooms config (if configured)
bot_config = self.config.get_bot_config()
allowed_rooms = bot_config.get('allowed_rooms', [])
if allowed_rooms and room_id not in allowed_rooms:
return False

room = self.client.rooms.get(room_id)
if not room:
return False

# Check if bot is a member of the room
if self.user_id not in room.users:
return False

# Check power levels
try:
# Get the bot's power level
bot_power_level = room.power_levels.get_user_level(self.user_id)
# Get the required power level to send messages
required_level = room.power_levels.get_event_level("m.room.message")

return bot_power_level >= required_level
except (AttributeError, KeyError):
# If we can't determine power levels, assume we can send
# (default behavior - will fail gracefully if not allowed)
return True

def get_status_info(self) -> dict:
"""Get centralized bot status information.

This method provides a unified structure for bot status that can be used
by both the TUI and command handlers for consistent display.

Returns:
Dictionary with status information including:
- version: Full version string with git commit if applicable
- platform: OS platform and version
- architecture: System architecture
- runtime: Runtime type (binary or interpreter)
- cpu_model: CPU model name (if available)
- cpu_percent: Current CPU usage percentage (if available)
- memory: Memory usage info (if available)
- metrics: Runtime metrics (messages_sent, requests_received, etc.)
- matrix_status: Connection status ('Connected' or 'Disconnected')
- matrix_homeserver: Matrix homeserver URL
- matrix_user_id: Bot's Matrix user ID
- matrix_device_id: Bot's device ID
- matrix_encrypted: Whether E2E encryption is enabled
- semaphore_status: Semaphore connection status
- uptime: Bot uptime in seconds
"""
from chatrixcd import __version_full__

status = {
'version': __version_full__,
'platform': f"{platform.system()} {platform.release()}",
'architecture': platform.machine(),
'metrics': self.metrics.copy(),
'uptime': int(time.time() * 1000) - self.start_time # milliseconds
}

# Determine runtime type
if getattr(sys, 'frozen', False):
status['runtime'] = "Binary (compiled)"
else:
status['runtime'] = f"Python {platform.python_version()} (interpreter)"

# CPU model (try to get, but don't fail if unavailable)
try:
cpu_model = None
if platform.system() == "Linux":
try:
with open('/proc/cpuinfo', 'r') as f:
for line in f:
if 'model name' in line:
cpu_model = line.split(':')[1].strip()
break
except Exception:
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already applied in commit 53844e4. Explanatory comments have been added to all empty except clauses throughout the get_status_info() method.

# Ignore errors reading /proc/cpuinfo; not critical for bot operation
pass
elif platform.system() == "Darwin": # macOS
try:
result = subprocess.run(['sysctl', '-n', 'machdep.cpu.brand_string'],
capture_output=True, text=True, timeout=1)
if result.returncode == 0:
cpu_model = result.stdout.strip()
except Exception:
# Ignore errors getting CPU model on macOS; not critical for bot operation
pass
elif platform.system() == "Windows":
try:
result = subprocess.run(['wmic', 'cpu', 'get', 'name'],
capture_output=True, text=True, timeout=1)
if result.returncode == 0:
lines_output = result.stdout.strip().split('\n')
if len(lines_output) > 1:
cpu_model = lines_output[1].strip()
except Exception:
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Suggested change
except Exception:
except Exception:
# Intentionally ignore errors in Windows CPU model detection; non-critical.

Copilot uses AI. Check for mistakes.
# Ignore errors getting CPU model on Windows; not critical for bot operation
pass

if cpu_model:
status['cpu_model'] = cpu_model
except Exception:
# Failed to get CPU model; this is non-critical and will be omitted from status
pass

# System resources
try:
status['cpu_percent'] = psutil.cpu_percent(interval=0.1)
status['memory'] = {
'percent': psutil.virtual_memory().percent,
'used': psutil.virtual_memory().used // (1024**2),
'total': psutil.virtual_memory().total // (1024**2)
}
except Exception:
# Failed to gather system resource info; not critical for bot operation
pass

# Matrix status
if self.client:
status['matrix_status'] = 'Connected' if (hasattr(self.client, 'logged_in') and self.client.logged_in) else 'Disconnected'
status['matrix_homeserver'] = self.client.homeserver
status['matrix_user_id'] = self.client.user_id
if hasattr(self.client, 'device_id'):
status['matrix_device_id'] = self.client.device_id
status['matrix_encrypted'] = hasattr(self.client, 'olm') and self.client.olm is not None
else:
status['matrix_status'] = 'Not initialized'

# Semaphore status (simple check if client exists)
status['semaphore_status'] = 'Connected' if self.semaphore else 'Unknown'

return status

async def sync_callback(self, response: SyncResponse):
"""Handle sync responses and manage encryption keys.

Expand Down
Loading
Loading