Skip to content
Merged
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()
61 changes: 61 additions & 0 deletions chatrixcd/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,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 +551,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 +815,21 @@
ignore_unverified_devices=True
)

# Track metrics
self.metrics['messages_sent'] += 1
# Count emojis in the message (simple count of common emoji ranges)
import re
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.

The re module should be imported at the module level, not inside the method. This import is executed every time send_message() is called, which is inefficient. Move import re to the top of the file with other imports.

Copilot uses AI. Check for mistakes.
emoji_pattern = re.compile("["
"\U0001F600-\U0001F64F" # emoticons
"\U0001F300-\U0001F5FF" # symbols & pictographs
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F1E0-\U0001F1FF" # flags (iOS)
"\U00002702-\U000027B0" # dingbats
"\U000024C2-\U0001F251"
"]+", flags=re.UNICODE)
emojis = emoji_pattern.findall(message)
self.metrics['emojis_used'] += len(emojis)
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.

The emoji pattern is compiled on every call to send_message(), which is inefficient. Move the emoji pattern compilation to module level or class initialization. Additionally, the current implementation only counts the number of matches (len(emojis)), but according to the documentation (METRICS_AND_COMMANDS.md line 64) and tests (test_metrics_and_version.py lines 100-106), consecutive emojis should be counted individually. The correct implementation should be:

self.metrics['emojis_used'] += sum(len(match) for match in emojis)

This will properly count "🎉🎊🎈" as 3 emojis instead of 1.

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.

Applied in commit 53844e4:

  • Moved re import to module level
  • Created module-level EMOJI_PATTERN constant (compiled once)
  • Fixed emoji counting to use sum(len(match) for match in emojis) to properly count individual emojis in consecutive sequences

The fix correctly counts "🎉🎊🎈" as 3 emojis instead of 1 match.


# Return the event_id for potential reactions
if hasattr(response, 'event_id'):
return response.event_id
Expand Down Expand Up @@ -830,6 +858,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 +910,36 @@
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.

Args:
room_id: Room ID to check

Returns:
True if bot can send messages, False otherwise
"""
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

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

Expand Down
122 changes: 107 additions & 15 deletions chatrixcd/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
import platform
import socket
import psutil
import sys
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.

Import of 'sys' is not used.

Suggested change
import sys

Copilot uses AI. Check for mistakes.
from typing import Dict, Any, Optional, List
from nio import MatrixRoom, RoomMessageText
from chatrixcd.semaphore import SemaphoreClient
from chatrixcd.aliases import AliasManager
from chatrixcd.messages import MessageManager
from chatrixcd import __version__
from chatrixcd import __version__, __version_full__

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -515,19 +516,54 @@ async def _list_rooms(self, room_id: str, greeting: str):
await self.bot.send_message(room_id, plain_msg, html_msg, msgtype="m.notice")
return

# Check if redaction is enabled
redact_enabled = self.config.get('redact', False)

# Build room list with send permission info
room_list = []
for room_id_key, room in rooms.items():
can_send = self.bot.can_send_message_in_room(room_id_key)

# Skip rooms where bot can't send if redaction is enabled
if redact_enabled and not can_send:
continue

room_name = room.display_name or "Unknown"
room_list.append({
'room_id': room_id_key,
'room_name': room_name,
'can_send': can_send
})

if not room_list:
plain_msg = f"{greeting} No rooms to display (all rooms filtered). 🏠"
html_msg = self.markdown_to_html(plain_msg)
await self.bot.send_message(room_id, plain_msg, html_msg, msgtype="m.notice")
return

# Plain text version
message = f"{greeting} Here's where I hang out! 🏠\n\n"
message += "**Rooms I'm In**\n\n"
for room_id_key, room in rooms.items():
room_name = room.display_name or "Unknown"
message += f"• **{room_name}**\n `{room_id_key}`\n"
for room_info in room_list:
status = "✅ Can send" if room_info['can_send'] else "❌ Cannot send"
message += f"• **{room_info['room_name']}** - {status}\n `{room_info['room_id']}`\n"

# HTML version with table
# HTML version with table and color coding
greeting_html = self.markdown_to_html(greeting)
table_html = '<table><thead><tr><th>Room Name</th><th>Room ID</th></tr></thead><tbody>'
for room_id_key, room in rooms.items():
room_name = html.escape(room.display_name or "Unknown")
table_html += f'<tr><td><strong>{room_name}</strong></td><td><code>{html.escape(room_id_key)}</code></td></tr>'
table_html = '<table><thead><tr><th>Room Name</th><th>Room ID</th><th>Send Status</th></tr></thead><tbody>'
for room_info in room_list:
room_name = html.escape(room_info['room_name'])
room_id_escaped = html.escape(room_info['room_id'])

# Color code based on send permission
if room_info['can_send']:
status_html = self._color_success("✅ Can send")
row_class = ""
else:
status_html = self._color_error("❌ Cannot send")
row_class = ' style="opacity: 0.6;"'

table_html += f'<tr{row_class}><td><strong>{room_name}</strong></td><td><code>{room_id_escaped}</code></td><td>{status_html}</td></tr>'
table_html += '</tbody></table>'

html_message = f"{greeting_html} Here's where I hang out! 🏠<br/><br/><strong>Rooms I'm In</strong><br/><br/>{table_html}"
Expand Down Expand Up @@ -1945,16 +1981,52 @@ def _gather_bot_system_info(self) -> tuple:
lines = []
info_dict = {}

# Basic bot info
lines.append(f"• **Version:** {__version__}")
# Basic bot info with full version (includes git commit if applicable)
lines.append(f"• **Version:** {__version_full__}")
lines.append(f"• **Platform:** {platform.system()} {platform.release()}")
lines.append(f"• **Architecture:** {platform.machine()}")
lines.append(f"• **Python:** {platform.python_version()}")

info_dict['version'] = __version__
# Determine runtime type (binary vs interpreter)
if getattr(sys, 'frozen', False):
runtime_type = "Binary (compiled)"
else:
runtime_type = f"Python {platform.python_version()} (interpreter)"
lines.append(f"• **Runtime:** {runtime_type}")

info_dict['version'] = __version_full__
info_dict['platform'] = f"{platform.system()} {platform.release()}"
info_dict['architecture'] = platform.machine()
info_dict['python'] = platform.python_version()
info_dict['runtime'] = runtime_type

# CPU model name
try:
cpu_model = "Unknown"
if platform.system() == "Linux":
with open('/proc/cpuinfo', 'r') as f:
for line in f:
if 'model name' in line:
cpu_model = line.split(':')[1].strip()
break
elif platform.system() == "Darwin": # macOS
import subprocess
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()
elif platform.system() == "Windows":
import subprocess
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()

if cpu_model != "Unknown":
lines.append(f"• **CPU Model:** {cpu_model}")
info_dict['cpu_model'] = cpu_model
except Exception as e:
logger.debug(f"Could not get CPU model: {e}")

# System resources
try:
Expand All @@ -1967,6 +2039,15 @@ def _gather_bot_system_info(self) -> tuple:
except Exception as e:
logger.debug(f"Could not get system resources: {e}")

# Runtime metrics
if hasattr(self.bot, 'metrics'):
metrics = self.bot.metrics
lines.append(f"• **Messages Sent:** {metrics['messages_sent']}")
lines.append(f"• **Requests Received:** {metrics['requests_received']}")
lines.append(f"• **Errors:** {metrics['errors']}")
lines.append(f"• **Emojis Used:** {metrics['emojis_used']} 😊")
info_dict['metrics'] = metrics

# Network information (only if not redacting)
redact_enabled = self.config.get('redact', False)
if not redact_enabled:
Expand Down Expand Up @@ -2053,13 +2134,24 @@ def _build_info_html_tables(self, bot_info: dict, matrix_info: dict,
['Version', bot_info['version']],
['Platform', bot_info['platform']],
['Architecture', bot_info['architecture']],
['Python', bot_info['python']],
['Runtime', bot_info.get('runtime', 'Unknown')],
]
if 'cpu_model' in bot_info:
bot_rows.append(['CPU Model', bot_info['cpu_model']])
if 'cpu_percent' in bot_info:
bot_rows.append(['CPU Usage', f"{bot_info['cpu_percent']:.1f}%"])
if 'memory' in bot_info:
mem = bot_info['memory']
bot_rows.append(['Memory Usage', f"{mem.percent:.1f}% ({mem.used // (1024**2)} MB / {mem.total // (1024**2)} MB)"])

# Add metrics if available
if 'metrics' in bot_info:
metrics = bot_info['metrics']
bot_rows.append(['Messages Sent', str(metrics['messages_sent'])])
bot_rows.append(['Requests Received', str(metrics['requests_received'])])
bot_rows.append(['Errors', str(metrics['errors'])])
bot_rows.append(['Emojis Used', f"{metrics['emojis_used']} 😊"])

if 'hostname' in bot_info and not redact_enabled:
bot_rows.append(['Hostname', bot_info['hostname']])
if 'ipv4' in bot_info and not redact_enabled:
Expand Down
6 changes: 3 additions & 3 deletions chatrixcd/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import logging
import os
import sys
from chatrixcd import __version__
from chatrixcd import __version__, __version_full__
from chatrixcd.config import Config
from chatrixcd.bot import ChatrixBot
from chatrixcd.redactor import SensitiveInfoRedactor, RedactingFilter
Expand Down Expand Up @@ -95,7 +95,7 @@ def parse_args():
parser.add_argument(
'-V', '--version',
action='version',
version=f'ChatrixCD {__version__}'
version=f'ChatrixCD {__version_full__}'
)

parser.add_argument(
Expand Down Expand Up @@ -390,7 +390,7 @@ def main():
setup_logging(verbosity=args.verbosity, color=False, redact=args.redact, log_file=log_file, tui_mode=False) # No color in daemon mode
logger = logging.getLogger(__name__)

logger.info(f"ChatrixCD {__version__} starting...")
logger.info(f"ChatrixCD {__version_full__} starting...")

# Validate configuration before starting
validation_errors = config.validate_schema()
Expand Down
Loading
Loading