diff --git a/bot.py b/bot.py index e7cb95b..b24efe2 100644 --- a/bot.py +++ b/bot.py @@ -118,6 +118,7 @@ IMPORTANT RULES — FOLLOW THESE STRICTLY: - If a user's Atlassian account is not connected, tell them to connect it at https://matrixhost.eu/settings and provide the link. - If user memories are provided, use them to personalize responses. Address users by name if known. - When asked to translate, provide ONLY the translation with no explanation. +- You can scan Popular Investors (PIs) on eToro using the pi_scan tool. Use it when users ask about PI activity, trading signals, or want to run a scan. The scan takes 3-8 minutes and detects allocation changes. - You can set reminders and scheduled messages. When users ask to be reminded of something, use the schedule_message tool. Parse natural language times like "in 2 hours", "tomorrow at 9am", "every Monday" into ISO 8601 datetime with Europe/Berlin timezone (unless user specifies otherwise).""" IMAGE_GEN_TOOLS = [{ @@ -389,7 +390,47 @@ SCHEDULER_TOOLS = [ }, ] -ALL_TOOLS = IMAGE_GEN_TOOLS + WEB_SEARCH_TOOLS + ATLASSIAN_TOOLS + ROOM_TOOLS + SCHEDULER_TOOLS + +PIRADAR_URL = os.environ.get("PIRADAR_URL", "http://127.0.0.1:8095") +PIRADAR_API_KEY = os.environ.get("PIRADAR_API_KEY", "") + +PI_SCAN_TOOLS = [ + { + "type": "function", + "function": { + "name": "pi_scan", + "description": "Run a PI (Popular Investor) scan on eToro. Fetches top PI rankings, compares portfolio allocations to previous snapshots, and detects symbols where PIs increased their allocation. Takes 3-8 minutes. Use when user asks to scan PIs, check PI activity, or run a trading scan.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + }, + { + "type": "function", + "function": { + "name": "pi_scan_status", + "description": "Get the status and metadata of the last PI scan (timestamp, PI count, signals, health score). Use when user asks about scan status or last scan results.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + }, + { + "type": "function", + "function": { + "name": "pi_scan_signals", + "description": "Get the signals (allocation increases) from the most recent PI scan. Returns symbol, direction, PI count, weighted score, and details. Use when user asks for latest signals or PI consensus.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + }, +] + +ALL_TOOLS = IMAGE_GEN_TOOLS + WEB_SEARCH_TOOLS + ATLASSIAN_TOOLS + ROOM_TOOLS + SCHEDULER_TOOLS + PI_SCAN_TOOLS ATLASSIAN_NOT_CONNECTED_MSG = ( "Your Atlassian account is not connected. " @@ -1276,6 +1317,14 @@ class Bot: self.client.add_to_device_callback(self.on_key_verification, KeyVerificationMac) self.client.add_to_device_callback(self.on_key_verification, KeyVerificationCancel) + # Cache display name for mention matching in group rooms + try: + dn_resp = await self.client.get_displayname(BOT_USER) + self._display_name = getattr(dn_resp, 'displayname', '') or '' + logger.info('Bot display name: %s', self._display_name) + except Exception: + self._display_name = '' + # Start reminder scheduler asyncio.create_task(self._reminder_scheduler()) @@ -1909,10 +1958,14 @@ class Bot: is_dm = room.member_count == 2 if not is_dm: bot_display = self.client.user_id.split(":")[0].lstrip("@") + # Also match display name (e.g. 'Claude') since Element uses it in mentions + bot_displayname = (getattr(self, '_display_name', '') or bot_display).lower() + body_lower = body.lower() mentioned = ( BOT_USER in body - or f"@{bot_display}" in body.lower() - or bot_display.lower() in body.lower() + or f"@{bot_display}" in body_lower + or bot_display.lower() in body_lower + or bot_displayname in body_lower ) if not mentioned: return @@ -2140,10 +2193,14 @@ class Bot: # Check if bot was @mentioned in the image body (caption) or skip body = (event.body or "").strip() bot_display = self.client.user_id.split(":")[0].lstrip("@") + # Also match display name (e.g. 'Claude') since Element uses it in mentions + bot_displayname = (getattr(self, '_display_name', '') or bot_display).lower() + body_lower = body.lower() mentioned = ( BOT_USER in body - or f"@{bot_display}" in body.lower() - or bot_display.lower() in body.lower() + or f"@{bot_display}" in body_lower + or bot_display.lower() in body_lower + or bot_displayname in body_lower ) if not mentioned: return @@ -2206,10 +2263,14 @@ class Bot: if not is_dm: body = (event.body or "").strip() bot_display = self.client.user_id.split(":")[0].lstrip("@") + # Also match display name (e.g. 'Claude') since Element uses it in mentions + bot_displayname = (getattr(self, '_display_name', '') or bot_display).lower() + body_lower = body.lower() mentioned = ( BOT_USER in body - or f"@{bot_display}" in body.lower() - or bot_display.lower() in body.lower() + or f"@{bot_display}" in body_lower + or bot_display.lower() in body_lower + or bot_displayname in body_lower ) if not mentioned: return @@ -2357,10 +2418,14 @@ class Bot: if not is_dm: body = (event.body or "").strip() bot_display = self.client.user_id.split(":")[0].lstrip("@") + # Also match display name (e.g. 'Claude') since Element uses it in mentions + bot_displayname = (getattr(self, '_display_name', '') or bot_display).lower() + body_lower = body.lower() mentioned = ( BOT_USER in body - or f"@{bot_display}" in body.lower() - or bot_display.lower() in body.lower() + or f"@{bot_display}" in body_lower + or bot_display.lower() in body_lower + or bot_displayname in body_lower ) if not mentioned: return @@ -2468,10 +2533,14 @@ class Bot: if not is_dm: body = (event.body or "").strip() bot_display = self.client.user_id.split(":")[0].lstrip("@") + # Also match display name (e.g. 'Claude') since Element uses it in mentions + bot_displayname = (getattr(self, '_display_name', '') or bot_display).lower() + body_lower = body.lower() mentioned = ( BOT_USER in body - or f"@{bot_display}" in body.lower() - or bot_display.lower() in body.lower() + or f"@{bot_display}" in body_lower + or bot_display.lower() in body_lower + or bot_displayname in body_lower ) if not mentioned: return @@ -2707,6 +2776,74 @@ class Bot: if tool_name == "cancel_reminder": return await self._cancel_reminder(sender, args.get("reminder_id", 0)) + + # PI Scan tools — no auth needed + if tool_name == "pi_scan": + try: + headers = {} + if PIRADAR_API_KEY: + headers["Authorization"] = f"Bearer {PIRADAR_API_KEY}" + async with httpx.AsyncClient(timeout=600.0) as hc: + resp = await hc.post(f"{PIRADAR_URL}/scan", headers=headers) + if resp.status_code == 200: + data = resp.json() + meta = data.get("scan_meta", {}) + signals = data.get("signals", []) + if "error" in data: + return f"PI Scan error: {data['error']}" + prev_ts = meta.get("previous_timestamp", "unknown") + result = f"PI Scan complete ({meta.get('duration_s', '?')}s)\n" + result += f"Time range: {prev_ts or 'first scan'} → {meta.get('timestamp', '?')}\n" + result += f"PIs: {meta.get('fetched', '?')}/{meta.get('pi_count', '?')} | Health: {meta.get('health_score', '?')}%\n" + if meta.get("seeded", 0) > 0: + result += f"Seeded {meta['seeded']} new PI snapshots (signals from next scan)\n" + if not signals: + result += "No signals detected." + else: + result += f"\n{len(signals)} signals (allocation increases >= {meta.get('compared', '?')} PIs compared):\n\n" + result += "Symbol | Direction | PIs | Score | Details\n" + result += "-------|-----------|-----|-------|--------\n" + for sig in signals[:25]: + d = sig.get("details", "")[:80] + result += f"{sig['symbol']} | {sig['direction']} | {sig['pi_count']} | {sig['weighted_score']} | {d}\n" + if len(signals) > 25: + result += f"... and {len(signals) - 25} more signals" + return result + return f"PI Scan failed: HTTP {resp.status_code}" + except Exception as e: + return f"PI Scan error: {e}" + + if tool_name == "pi_scan_status": + try: + async with httpx.AsyncClient(timeout=10.0) as hc: + resp = await hc.get(f"{PIRADAR_URL}/status") + if resp.status_code == 200: + meta = resp.json() + return f"Last scan: {meta.get('timestamp', '?')}\nDuration: {meta.get('duration_s', '?')}s | PIs: {meta.get('fetched', '?')}/{meta.get('pi_count', '?')} | Health: {meta.get('health_score', '?')}% | Signals: {meta.get('signals', '?')} | Seeded: {meta.get('seeded', 0)} | Compared: {meta.get('compared', 0)}" + return "No scan data yet." + except Exception as e: + return f"Status check failed: {e}" + + if tool_name == "pi_scan_signals": + try: + async with httpx.AsyncClient(timeout=10.0) as hc: + resp = await hc.get(f"{PIRADAR_URL}/signals") + if resp.status_code == 200: + data = resp.json() + signals = data.get("signals", []) + if not signals: + return "No signals from last scan." + result = f"Last signals ({data.get('timestamp', '?')}, {len(signals)} signals):\n\n" + result += "Symbol | PIs | Score | Details\n" + result += "-------|-----|-------|--------\n" + for sig in signals[:25]: + d = sig.get("details", "")[:80] + result += f"{sig['symbol']} | {sig['pi_count']} | {sig['weighted_score']} | {d}\n" + return result + return "No signal data." + except Exception as e: + return f"Signals fetch failed: {e}" + # Atlassian tools — need per-user token token = await self.atlassian.get_token(sender) if sender else None if not token: