conversations.im anti-ban

This commit is contained in:
Dita Aji Pratama 2026-08-02 23:38:50 +07:00
parent e5ade40f6b
commit b6ae0f9b3b
2 changed files with 427 additions and 34 deletions

View File

@ -102,6 +102,7 @@ AGENT_SKILLS = _yaml_get("agent", "skills", default="").strip().lower()
XMPP_ENABLED = str(_yaml_get("xmpp", "enabled", default="false")).strip().lower() in ("true", "1", "yes") XMPP_ENABLED = str(_yaml_get("xmpp", "enabled", default="false")).strip().lower() in ("true", "1", "yes")
XMPP_MUC_ROOMS = _yaml_get("xmpp", "muc_rooms", default="").strip() XMPP_MUC_ROOMS = _yaml_get("xmpp", "muc_rooms", default="").strip()
XMPP_NICKNAME = _yaml_get("xmpp", "nickname", default="").strip() XMPP_NICKNAME = _yaml_get("xmpp", "nickname", default="").strip()
XMPP_DEBUG = str(_yaml_get("xmpp", "debug", default="false")).strip().lower() in ("true", "1", "yes")
XMPP_SELECTIVE_RESPONSE = str(_yaml_get("xmpp", "selective_response", default="true")).strip().lower() in ("true", "1", "yes") XMPP_SELECTIVE_RESPONSE = str(_yaml_get("xmpp", "selective_response", default="true")).strip().lower() in ("true", "1", "yes")

View File

@ -1,7 +1,9 @@
import asyncio import asyncio
import logging
import random import random
import signal import signal
import threading import threading
import traceback
from datetime import datetime from datetime import datetime
from slixmpp import ClientXMPP from slixmpp import ClientXMPP
@ -14,30 +16,159 @@ from lib import personality
from lib import ragroleplay from lib import ragroleplay
# Anti-ban: delay constants for MUC rejoin behavior # Anti-ban: delay constants for MUC rejoin behavior
MUC_REJOIN_INITIAL_DELAY = 5.0 # detik, delay awal sebelum rejoin MUC_REJOIN_INITIAL_DELAY = 30.0 # detik, delay awal sebelum rejoin (lebih gentle)
MUC_REJOIN_BACKOFF_MULT = 2.0 # multiplier exponential backoff MUC_REJOIN_BACKOFF_MULT = 1.5 # multiplier exponential backoff (lebih gentle)
MUC_REJOIN_MAX_DELAY = 300.0 # detik, batas max backoff (5 menit) MUC_REJOIN_MAX_DELAY = 600.0 # detik, batas max backoff (10 menit)
MUC_REJOIN_COOLDOWN = 10.0 # detik, cooldown minimum antar rejoin attempt MUC_REJOIN_COOLDOWN = 60.0 # detik, cooldown minimum antar rejoin attempt
MUC_NICK_SUFFIX_MAX = 3 # max coba nick alternatif (anti-ban: jangan terlalu banyak) MUC_NICK_SUFFIX_MAX = 3 # max coba nick alternatif (anti-ban: jangan terlalu banyak)
MUC_JOIN_JITTER = 0.3 # jitter 30% untuk randomize delay
# Anti-ban: Message queue rate limiting
MSG_QUEUE_DELAY_MIN = 3.0 # detik, delay minimum antar pesan keluar
MSG_QUEUE_DELAY_MAX = 8.0 # detik, delay maksimum antar pesan keluar
MSG_QUEUE_JITTER = 0.25 # jitter 25% untuk randomize delay
MSG_TYPING_SPEED_MIN = 8.0 # chars/sec, minimum typing speed
MSG_TYPING_SPEED_MAX = 20.0 # chars/sec, maksimum typing speed
MSG_READ_DELAY_MIN = 2.0 # detik, minimum read delay
MSG_READ_DELAY_MAX = 5.0 # detik, maksimum read delay
# Anti-ban: Adaptive rate limiting (deteksi throttle dari server)
ADAPTIVE_WINDOW_SIZE = 20 # jumlah pesan terakhir untuk tracking
ADAPTIVE_ERROR_THRESHOLD = 0.15 # 15% error rate = server mulai throttle
ADAPTIVE_SLOW_THRESHOLD = 5.0 # detik, response time > ini = server lambat
ADAPTIVE_BACKOFF_MULT = 2.0 # multiplier delay saat throttle detected
ADAPTIVE_RECOVERY_TIME = 300 # detik, waktu tunggu sebelum recovery ke normal
# Anti-ban: Connection behavior (sangat penting untuk conversations.im)
CONNECTION_KEEPALIVE = True # enable keepalive
CONNECTION_KEEPALIVE_INTERVAL = 300 # detik, ping interval (5 menit, jangan terlalu sering)
AUTO_RECONNECT_ENABLED = False # JANGAN auto reconnect instant (bot-like)
RECONNECT_DELAY_MIN = 30.0 # detik, delay minimum sebelum reconnect manual
RECONNECT_DELAY_MAX = 120.0 # detik, delay maksimum sebelum reconnect
def _ts(): def _ts():
return datetime.now().strftime('%H:%M:%S') return datetime.now().strftime('%H:%M:%S')
def _setup_debug_logging():
"""Debug: aktifkan logging DEBUG slixmpp untuk menampilkan SEMUA stanza XML keluar/masuk.
Sumber utama diagnosa:
- [slixmpp.xmlstream.xmlstream] SEND: <xml> -> stanza yang benar2 dikirim ke server
- [slixmpp.xmlstream.xmlstream] RECV: <xml> -> stanza yang diterima dari server
"""
if not config.XMPP_DEBUG:
return
logging.basicConfig(
level=logging.DEBUG,
format='[%(asctime)s][%(name)s] %(message)s',
datefmt='%H:%M:%S',
force=True,
)
logging.getLogger('slixmpp').setLevel(logging.DEBUG)
print(f'[{_ts()}] DEBUG: XMPP debug logging ENABLED (semua stanza XML akan dicetak)', flush=True)
def _dbg(msg):
"""Debug: cetak pesan debug hanya jika config.XMPP_DEBUG aktif."""
if config.XMPP_DEBUG:
print(f'[{_ts()}] DEBUG: {msg}', flush=True)
def _add_jitter(delay: float, jitter_factor: float = MSG_QUEUE_JITTER) -> float:
"""Tambahkan jitter acak ke delay untuk menghindari pola terdeteksi."""
jitter = delay * jitter_factor
return delay + random.uniform(-jitter, jitter)
def _typing_delay(text: str) -> float: def _typing_delay(text: str) -> float:
"""Hitung delay mengetik (detik) proporsional dengan panjang teks.""" """Hitung delay mengetik (detik) proporsional dengan panjang teks."""
char_count = len(text) if text else 0 char_count = len(text) if text else 0
delay = char_count / config.TYPING_SPEED # Random typing speed untuk lebih human-like
return max(1.0, min(delay, config.TYPING_MAX)) typing_speed = random.uniform(MSG_TYPING_SPEED_MIN, MSG_TYPING_SPEED_MAX)
delay = char_count / typing_speed
# Clamp antara 1-15 detik, tambah jitter
return _add_jitter(max(1.0, min(delay, 15.0)))
async def _read_delay(): async def _read_delay():
"""Delay simulasi membaca pesan user.""" """Delay simulasi membaca pesan user."""
delay = random.uniform(config.READ_DELAY_MIN, config.READ_DELAY_MAX) delay = random.uniform(MSG_READ_DELAY_MIN, MSG_READ_DELAY_MAX)
await asyncio.sleep(delay) await asyncio.sleep(delay)
class AdaptiveRateLimiter:
"""Anti-ban: adaptive rate limiting yang mendeteksi throttle dari server."""
def __init__(self):
self._send_times: list[float] = [] # timestamp pengiriman pesan
self._errors: list[bool] = [] # apakah pengiriman error
self._response_times: list[float] = [] # waktu response server (jika ada)
self._throttle_detected = False
self._throttle_start: float | None = None
self._backoff_multiplier = 1.0
def record_send(self, success: bool, response_time: float | None = None):
"""Catat hasil pengiriman pesan."""
now = asyncio.get_event_loop().time()
self._send_times.append(now)
self._errors.append(not success)
if response_time is not None:
self._response_times.append(response_time)
# Keep only recent window
if len(self._send_times) > ADAPTIVE_WINDOW_SIZE:
self._send_times.pop(0)
self._errors.pop(0)
if len(self._response_times) > ADAPTIVE_WINDOW_SIZE:
self._response_times.pop(0)
self._check_throttle()
def _check_throttle(self):
"""Cek apakah server mulai throttle."""
if len(self._errors) < 5: # butuh minimal sample
return
# Hitung error rate
error_rate = sum(self._errors) / len(self._errors)
# Hitung avg response time
avg_response = 0
if self._response_times:
avg_response = sum(self._response_times) / len(self._response_times)
# Deteksi throttle: error rate tinggi ATAU response time lambat
was_throttled = self._throttle_detected
if error_rate >= ADAPTIVE_ERROR_THRESHOLD or avg_response >= ADAPTIVE_SLOW_THRESHOLD:
if not self._throttle_detected:
self._throttle_detected = True
self._throttle_start = asyncio.get_event_loop().time()
self._backoff_multiplier = ADAPTIVE_BACKOFF_MULT
print(f'[{_ts()}] ADAPTIVE: Throttle detected! '
f'error_rate={error_rate:.1%}, avg_response={avg_response:.1f}s, '
f'backing off x{self._backoff_multiplier}', flush=True)
else:
# Recovery: cek apakah sudah cukup waktu untuk kembali normal
if self._throttle_detected and self._throttle_start:
elapsed = asyncio.get_event_loop().time() - self._throttle_start
if elapsed >= ADAPTIVE_RECOVERY_TIME:
self._throttle_detected = False
self._backoff_multiplier = 1.0
print(f'[{_ts()}] ADAPTIVE: Recovered to normal rate', flush=True)
def get_delay_multiplier(self) -> float:
"""Dapatkan multiplier untuk delay saat ini."""
return self._backoff_multiplier
def is_throttled(self) -> bool:
"""Apakah sedang dalam kondisi throttle."""
return self._throttle_detected
class XMPPClient(ClientXMPP): class XMPPClient(ClientXMPP):
def __init__(self, jid, password, llm_client, tools_definition, TOOLS, def __init__(self, jid, password, llm_client, tools_definition, TOOLS,
TOOL_HANDLERS, build_system_prompt, agent_max_iterations, TOOL_HANDLERS, build_system_prompt, agent_max_iterations,
@ -66,11 +197,51 @@ class XMPPClient(ClientXMPP):
self._muc_rejoin_tasks: dict[str, asyncio.Task] = {} # room -> pending rejoin task self._muc_rejoin_tasks: dict[str, asyncio.Task] = {} # room -> pending rejoin task
self._muc_last_join: dict[str, datetime] = {} # room -> terakhir join (cooldown) self._muc_last_join: dict[str, datetime] = {} # room -> terakhir join (cooldown)
self.auto_reconnect = True # Anti-ban: Message queue untuk rate limiting
self._msg_queue: asyncio.Queue | None = None
self._msg_worker_task: asyncio.Task | None = None
self._msg_worker_running = False
self.register_plugin('xep_0030') # Anti-ban: Adaptive rate limiter
self.register_plugin('xep_0045') self._rate_limiter = AdaptiveRateLimiter()
self.register_plugin('xep_0199')
# Anti-ban: reconnect tracking (jangan instant reconnect)
self._reconnect_scheduled = False
self._last_disconnect: datetime | None = None
# Anti-ban: JANGAN auto reconnect instant (bot-like behavior)
self.auto_reconnect = AUTO_RECONNECT_ENABLED
# ── Anti-ban (conversations.im): ringkasan solusi yang membuat akun
# tidak dianggap spam/di-block (penyebab: klien default slixmpp
# mengiklankan identity `client/bot` + caps node slixmpp) ────────────
#
# 1. JANGAN kirim 'subscribe' balik saat ada yang subscribe (contact
# farming = red flag). auto_authorize=True utk tetap menerima,
# auto_subscribe=False utk tidak membalas subscribe.
# 2. Caps node netral (bukan http://slixmpp.com/ver/X.Y.Z) supaya klien
# tidak mudah dikenali sebagai slixmpp.
# 3. Disco identity `client/console` di-set di _on_session_start (setelah
# bind). CATATAN: slixmpp menyimpan identity per key `boundjid.full` —
# di __init__ boundjid masih bare-JID, lookup runtime (setelah bind)
# memakai JID ber-resource, sehingga identity di __init__ TIDAK pernah
# ditemukan dan server melihat fallback `client/bot`. Harus di session_start.
self.roster.auto_authorize = True # terima subscribe masuk (sopan)
self.roster.auto_subscribe = False # JANGAN kirim subscribe balik
# Anti-ban: register plugin dengan konfigurasi gentle
self.register_plugin('xep_0030') # Service Discovery
self.register_plugin('xep_0045') # MUC
self.register_plugin('xep_0199', { # XMPP Ping
'keepalive': CONNECTION_KEEPALIVE,
'interval': CONNECTION_KEEPALIVE_INTERVAL,
'timeout': 30,
})
# Muat plugin sekarang agar bisa set caps node sebelum konek
self.init_plugins()
if self.plugin.get('xep_0115', None) is not None:
self.plugin['xep_0115'].caps_node = 'https://hendrik.local/caps'
self.add_event_handler('session_start', self._on_session_start) self.add_event_handler('session_start', self._on_session_start)
self.add_event_handler('message', self._on_message) self.add_event_handler('message', self._on_message)
@ -78,6 +249,11 @@ class XMPPClient(ClientXMPP):
self.add_event_handler('disconnected', self._on_disconnected) self.add_event_handler('disconnected', self._on_disconnected)
self.add_event_handler('connected', self._on_connected) self.add_event_handler('connected', self._on_connected)
self.add_event_handler('groupchat_presence', self._on_muc_presence) self.add_event_handler('groupchat_presence', self._on_muc_presence)
# Anti-ban: handler untuk melihat error dari server (perlu selalu aktif)
self.add_event_handler('message_error', self._on_message_error)
self.add_event_handler('stream_error', self._on_stream_error)
# Anti-ban: kontrol manual atas subscription request (jangan auto-subscribe balik)
self.add_event_handler('roster_subscription_request', self._on_roster_subscription_request)
def _get_muc_nick(self, room: str) -> str: def _get_muc_nick(self, room: str) -> str:
"""Anti-ban: resolve nick untuk room, coba nick alternatif kalau conflict.""" """Anti-ban: resolve nick untuk room, coba nick alternatif kalau conflict."""
@ -89,10 +265,12 @@ class XMPPClient(ClientXMPP):
return f"{base}_{suffix}" return f"{base}_{suffix}"
def _calc_rejoin_delay(self, room: str) -> float: def _calc_rejoin_delay(self, room: str) -> float:
"""Anti-ban: hitung delay rejoin dengan exponential backoff.""" """Anti-ban: hitung delay rejoin dengan exponential backoff + jitter."""
attempts = self._muc_rejoin_attempts.get(room, 0) attempts = self._muc_rejoin_attempts.get(room, 0)
delay = MUC_REJOIN_INITIAL_DELAY * (MUC_REJOIN_BACKOFF_MULT ** attempts) delay = MUC_REJOIN_INITIAL_DELAY * (MUC_REJOIN_BACKOFF_MULT ** attempts)
return min(delay, MUC_REJOIN_MAX_DELAY) delay = min(delay, MUC_REJOIN_MAX_DELAY)
# Tambah jitter untuk menghindari pola terdeteksi
return _add_jitter(delay, MUC_JOIN_JITTER)
def _schedule_muc_rejoin(self, room: str): def _schedule_muc_rejoin(self, room: str):
"""Anti-ban: schedule rejoin room dengan backoff & cooldown.""" """Anti-ban: schedule rejoin room dengan backoff & cooldown."""
@ -144,11 +322,15 @@ class XMPPClient(ClientXMPP):
# _muc_ready akan di-set oleh _on_muc_presence saat join berhasil # _muc_ready akan di-set oleh _on_muc_presence saat join berhasil
self._muc_rejoin_attempts.pop(room, None) self._muc_rejoin_attempts.pop(room, None)
self._muc_rejoin_attempts.pop("_nick_" + room, None) self._muc_rejoin_attempts.pop("_nick_" + room, None)
# Catat sukses untuk adaptive rate limiting
self._rate_limiter.record_send(success=True)
print(f'[{_ts()}] MUC [{room}] Rejoin successful as {nick}', flush=True) print(f'[{_ts()}] MUC [{room}] Rejoin successful as {nick}', flush=True)
except asyncio.CancelledError: except asyncio.CancelledError:
print(f'[{_ts()}] MUC [{room}] Rejoin cancelled', flush=True) print(f'[{_ts()}] MUC [{room}] Rejoin cancelled', flush=True)
except Exception as e: except Exception as e:
print(f'[{_ts()}] MUC [{room}] Rejoin failed: {e}', flush=True) print(f'[{_ts()}] MUC [{room}] Rejoin failed: {e}', flush=True)
# Catat error untuk adaptive rate limiting
self._rate_limiter.record_send(success=False)
# Anti-ban: handle 409 Conflict - nick sudah dipakai orang lain # Anti-ban: handle 409 Conflict - nick sudah dipakai orang lain
if '409' in str(e) or 'conflict' in str(e).lower(): if '409' in str(e) or 'conflict' in str(e).lower():
nick_attempts = self._muc_rejoin_attempts.get("_nick_" + room, 0) nick_attempts = self._muc_rejoin_attempts.get("_nick_" + room, 0)
@ -169,6 +351,10 @@ class XMPPClient(ClientXMPP):
async def _on_connected(self, event): async def _on_connected(self, event):
print(f'[{_ts()}] XMPP connected', flush=True) print(f'[{_ts()}] XMPP connected', flush=True)
_dbg(f'connected state: authenticated={self.authenticated}, bound={self.bound}, '
f'sessionstarted={self.sessionstarted}, '
f'_session_started={getattr(self, "_session_started", None)}')
_dbg(f'connected via: {self.transport}')
async def _on_disconnected(self, event): async def _on_disconnected(self, event):
print(f'[{_ts()}] XMPP disconnected', flush=True) print(f'[{_ts()}] XMPP disconnected', flush=True)
@ -179,11 +365,78 @@ class XMPPClient(ClientXMPP):
print(f'[{_ts()}] MUC [{room}] Cancelled pending rejoin (disconnected)', flush=True) print(f'[{_ts()}] MUC [{room}] Cancelled pending rejoin (disconnected)', flush=True)
self._muc_rejoin_tasks.clear() self._muc_rejoin_tasks.clear()
# Anti-ban: schedule manual reconnect dengan delay (jangan instant)
if not self._stopped.is_set() and not self._reconnect_scheduled:
self._reconnect_scheduled = True
self._last_disconnect = datetime.now()
delay = random.uniform(RECONNECT_DELAY_MIN, RECONNECT_DELAY_MAX)
print(f'[{_ts()}] Will attempt reconnect in {delay:.0f}s...', flush=True)
asyncio.create_task(self._delayed_reconnect(delay))
async def _delayed_reconnect(self, delay: float):
"""Anti-ban: reconnect dengan delay, bukan instant."""
await asyncio.sleep(delay)
if self._stopped.is_set():
return
print(f'[{_ts()}] Attempting manual reconnect...', flush=True)
try:
await self.connect()
except Exception as e:
print(f'[{_ts()}] Reconnect failed: {e}', flush=True)
# Schedule another reconnect with longer delay
self._reconnect_scheduled = False
self._on_disconnected(None)
else:
self._reconnect_scheduled = False
async def _on_session_start(self, event): async def _on_session_start(self, event):
self.send_presence() _dbg('--- SESSION START ---')
self.get_roster() _dbg(f'session state: authenticated={self.authenticated}, bound={self.bound}, '
f'sessionstarted={self.sessionstarted}, '
f'_session_started={getattr(self, "_session_started", None)}')
_dbg(f'boundjid: full={self.boundjid.full}, bare={self.boundjid.bare}, '
f'resource={self.boundjid.resource}, host={self.boundjid.host}')
print(f'[{_ts()}] XMPP online as {self.boundjid.full}', flush=True) print(f'[{_ts()}] XMPP online as {self.boundjid.full}', flush=True)
for room in self._muc_rooms:
# Anti-ban: set disco identity SEKARANG (setelah bind, boundjid ber-resource)
# supaya identity masuk ke caps ver (presence) DAN respon disco#info server.
# Sebelumnya di __init__ -> tersimpan di key bare-JID -> tak pernah ditemukan
# saat runtime -> server melihat identity fallback `client/bot`.
self.plugin['xep_0030'].add_identity(
category='client', itype='console', name=config.AGENT_CHARACTER.title() or 'Hendrik',
)
try:
local_info = await self.plugin['xep_0030'].get_info(local=True)
if 'disco_info' in local_info:
identities = local_info['disco_info']['identities']
else:
identities = local_info['identities']
_dbg(f'disco identity (local) after set: {identities}')
except Exception as e:
_dbg(f'disco identity check failed: {e}')
# Anti-ban: send presence + request roster (wajib sebelum kirim pesan DM)
print(f'[{_ts()}] Sending initial presence...', flush=True)
self.send_presence()
print(f'[{_ts()}] Requesting roster...', flush=True)
self.get_roster()
# Anti-ban: delay sebelum join MUC pertama agar startup tidak terlihat bot
if self._muc_rooms:
pre_delay = random.uniform(5.0, 15.0)
print(f'[{_ts()}] MUC pre-join delay {pre_delay:.1f}s (anti-ban)...', flush=True)
await asyncio.sleep(pre_delay)
# Anti-ban: delay sebelum join MUC untuk menghindari koneksi yang terlalu agresif
for i, room in enumerate(self._muc_rooms):
# Delay antar room join (3-8 detik per room)
if i > 0:
join_delay = random.uniform(3.0, 8.0)
print(f'[{_ts()}] MUC [{room}] Waiting {join_delay:.1f}s before join...', flush=True)
await asyncio.sleep(join_delay)
# Anti-ban: retry join dengan incremental delay & nick fallback # Anti-ban: retry join dengan incremental delay & nick fallback
success = False success = False
for attempt in range(1, 4): for attempt in range(1, 4):
@ -212,8 +465,8 @@ class XMPPClient(ClientXMPP):
print(f'[{_ts()}] MUC [{room}] All nick variations exhausted', flush=True) print(f'[{_ts()}] MUC [{room}] All nick variations exhausted', flush=True)
break break
elif attempt < 3: elif attempt < 3:
# Anti-ban: error biasa, wait before retry (2s, 4s) # Anti-ban: error biasa, wait before retry (5s, 10s, 15s)
retry_delay = 2.0 * attempt retry_delay = 5.0 * attempt
print(f'[{_ts()}] MUC [{room}] Retrying in {retry_delay:.0f}s...', flush=True) print(f'[{_ts()}] MUC [{room}] Retrying in {retry_delay:.0f}s...', flush=True)
await asyncio.sleep(retry_delay) await asyncio.sleep(retry_delay)
if not success: if not success:
@ -229,7 +482,36 @@ class XMPPClient(ClientXMPP):
if not body: if not body:
return return
print(f'[{_ts()}] DM from {jid}: {body[:60]}', flush=True) print(f'[{_ts()}] DM from {jid}: {body[:60]}', flush=True)
threading.Thread(target=self._process_dm, args=(jid, body), daemon=True).start() # Anti-ban: proses langsung di event loop, bukan thread baru
# Ini memastikan msg.send() dipanggil dari thread yang benar
asyncio.create_task(self._process_dm_async(jid, body))
def _on_message_error(self, msg):
"""Anti-ban: handle error message dari server."""
print(f'[{_ts()}] MESSAGE ERROR from server:', flush=True)
print(f'[{_ts()}] Type: {msg.get("type", "unknown")}', flush=True)
print(f'[{_ts()}] From: {msg.get("from", "unknown")}', flush=True)
print(f'[{_ts()}] To: {msg.get("to", "unknown")}', flush=True)
print(f'[{_ts()}] Error: {msg.get("error", "unknown")}', flush=True)
print(f'[{_ts()}] Full stanza: {msg}', flush=True)
# Catat error untuk adaptive rate limiting
self._rate_limiter.record_send(success=False)
def _on_stream_error(self, error):
"""Anti-ban: handle stream error dari server."""
print(f'[{_ts()}] STREAM ERROR from server:', flush=True)
print(f'[{_ts()}] Condition: {error.get("condition", "unknown")}', flush=True)
print(f'[{_ts()}] Text: {error.get("text", "unknown")}', flush=True)
print(f'[{_ts()}] Full error: {error}', flush=True)
async def _on_roster_subscription_request(self, presence):
print(f'[{_ts()}] Subscription request from {presence["from"]}', flush=True)
# Anti-ban: TIDAK merespons subscribe otomatis (auto_subscribe=False).
# Balasan 'subscribe' balik adalah red flag (contact farming) di conversations.im.
_dbg(f'NOT auto-responding subscribe from {presence["from"]} '
f'(auto_subscribe={self.roster.auto_subscribe}, '
f'auto_authorize={self.roster.auto_authorize})')
def _on_groupchat_message(self, msg): def _on_groupchat_message(self, msg):
if msg['type'] != 'groupchat': if msg['type'] != 'groupchat':
@ -245,7 +527,8 @@ class XMPPClient(ClientXMPP):
if not body: if not body:
return return
print(f'[{_ts()}] MUC [{room}] <{nick}>: {body[:60]}', flush=True) print(f'[{_ts()}] MUC [{room}] <{nick}>: {body[:60]}', flush=True)
threading.Thread(target=self._process_muc, args=(room, nick, body), daemon=True).start() # Anti-ban: proses langsung di event loop, bukan thread baru
asyncio.create_task(self._process_muc_async(room, nick, body))
def _is_my_nick(self, room: str, nick: str) -> bool: def _is_my_nick(self, room: str, nick: str) -> bool:
"""Anti-ban: cek apakah nick yang dimasukan sesuai dengan nick bot di room.""" """Anti-ban: cek apakah nick yang dimasukan sesuai dengan nick bot di room."""
@ -279,7 +562,8 @@ class XMPPClient(ClientXMPP):
else: else:
print(f'[{_ts()}] MUC [{room}] <{nick}> joined (type={ptype})', flush=True) print(f'[{_ts()}] MUC [{room}] <{nick}> joined (type={ptype})', flush=True)
def _process_dm(self, jid, body): async def _process_dm_async(self, jid, body):
"""Anti-ban: versi async dari _process_dm, dipanggil dari event loop."""
session = self._session_mgr.get_or_create( session = self._session_mgr.get_or_create(
jid, self._build_system_prompt( jid, self._build_system_prompt(
tools_definition=self._tools_def, tools_definition=self._tools_def,
@ -289,7 +573,10 @@ class XMPPClient(ClientXMPP):
) )
session.cancel_timer() session.cancel_timer()
self.send_presence_subscription(pto=jid, ptype='subscribed') # Anti-ban: JANGAN kirim presence subscription otomatis
# Ini adalah red flag untuk server (contact farming behavior)
# Hanya kirim jika user explicitly request
# self.send_presence_subscription(pto=jid, ptype='subscribed')
if body == ':new': if body == ':new':
self._session_mgr.reset(jid) self._session_mgr.reset(jid)
@ -334,8 +621,7 @@ class XMPPClient(ClientXMPP):
self._schedule_send(jid, f'> {body}\nThinking...') self._schedule_send(jid, f'> {body}\nThinking...')
# Delay 1: simulasi membaca pesan user # Delay 1: simulasi membaca pesan user
if self._loop and not self._loop.is_closed(): await _read_delay()
asyncio.run_coroutine_threadsafe(_read_delay(), self._loop)
my_name = personality.PERSONALITY.name my_name = personality.PERSONALITY.name
quote = body quote = body
@ -396,7 +682,8 @@ class XMPPClient(ClientXMPP):
# DM: timeout 24 jam (efektif tidak auto-close), MUC tetap 5 menit # DM: timeout 24 jam (efektif tidak auto-close), MUC tetap 5 menit
session.start_timer(86400, self._timeout_session, jid, 'chat') session.start_timer(86400, self._timeout_session, jid, 'chat')
def _process_muc(self, room, nick, body): async def _process_muc_async(self, room, nick, body):
"""Anti-ban: versi async dari _process_muc, dipanggil dari event loop."""
session = self._session_mgr.get_or_create( session = self._session_mgr.get_or_create(
room, self._build_system_prompt( room, self._build_system_prompt(
tools_definition=self._tools_def, tools_definition=self._tools_def,
@ -419,8 +706,7 @@ class XMPPClient(ClientXMPP):
self._schedule_send(room, f'> [{nick}] {body}\nThinking...', mtype='groupchat') self._schedule_send(room, f'> [{nick}] {body}\nThinking...', mtype='groupchat')
# Delay 1: simulasi membaca pesan user # Delay 1: simulasi membaca pesan user
if self._loop and not self._loop.is_closed(): await _read_delay()
asyncio.run_coroutine_threadsafe(_read_delay(), self._loop)
my_name = personality.PERSONALITY.name my_name = personality.PERSONALITY.name
quote = f'[{nick}] {body}' quote = f'[{nick}] {body}'
@ -482,12 +768,89 @@ class XMPPClient(ClientXMPP):
return execute_tool(tool_call, self._TOOL_HANDLERS) return execute_tool(tool_call, self._TOOL_HANDLERS)
def _schedule_send(self, to, body, mtype='chat'): def _schedule_send(self, to, body, mtype='chat'):
if self._loop and not self._loop.is_closed(): """Anti-ban: enqueue pesan ke queue, bukan langsung kirim."""
asyncio.run_coroutine_threadsafe( if self._msg_queue and self._loop and not self._loop.is_closed():
self._send_coro(to, body, mtype), self._loop try:
) self._msg_queue.put_nowait((to, body, mtype))
print(f'[{_ts()}] Queued message to {to} ({len(body)} chars)', flush=True)
except asyncio.QueueFull:
print(f'[{_ts()}] WARNING: Message queue full, dropping message to {to}', flush=True)
else: else:
print(f'[{_ts()}] WARNING: cannot send to {to} — loop unavailable', flush=True) print(f'[{_ts()}] WARNING: cannot queue message to {to} — queue unavailable', flush=True)
async def _msg_worker(self):
"""Anti-ban: worker coroutine yang mengirim pesan dari queue dengan rate limiting."""
print(f'[{_ts()}] Message queue worker started', flush=True)
self._msg_worker_running = True
while self._msg_worker_running:
try:
# Ambil pesan dari queue dengan timeout
try:
to, body, mtype = await asyncio.wait_for(
self._msg_queue.get(), timeout=1.0
)
except asyncio.TimeoutError:
continue
# Adaptive: apply backoff multiplier jika throttle detected
base_delay = random.uniform(MSG_QUEUE_DELAY_MIN, MSG_QUEUE_DELAY_MAX)
multiplier = self._rate_limiter.get_delay_multiplier()
delay = _add_jitter(base_delay) * multiplier
if multiplier > 1.0:
print(f'[{_ts()}] ADAPTIVE: Throttled, delay {delay:.1f}s (x{multiplier}) to {to}', flush=True)
else:
print(f'[{_ts()}] Pre-send delay: {delay:.1f}s to {to}', flush=True)
await asyncio.sleep(delay)
# Kirim pesan dan catat hasilnya
send_start = asyncio.get_event_loop().time()
send_success = False
try:
# Anti-ban: gunakan full JID untuk mfrom (server strict tentang ini)
mfrom = self.boundjid.full if self.boundjid else None
msg = self.make_message(mto=to, mbody=body, mtype=mtype, mfrom=mfrom)
# Debug: snapshot state koneksi + stanza XML lengkap sebelum kirim.
_dbg(f'SEND STATE: to={to}, mtype={mtype}, '
f'_session_started={getattr(self, "_session_started", None)}, '
f'authenticated={self.authenticated}, bound={self.bound}')
_dbg(f'Stanza to send (full): {msg}')
msg.send()
send_success = True
print(f'[{_ts()}] Sent to {to} ({len(body)} chars)', flush=True)
except Exception as e:
print(f'[{_ts()}] SEND ERROR to {to}: {e}', flush=True)
print(traceback.format_exc(), flush=True)
# Catat error untuk adaptive rate limiting
self._rate_limiter.record_send(success=False)
if send_success:
# Catat sukses (tanpa response time karena XMPP async)
self._rate_limiter.record_send(success=True)
# Mark task done
self._msg_queue.task_done()
# Small delay antara pesan untuk menghindari burst
# Adaptive: lebih lama jika throttled
inter_delay = random.uniform(0.5, 1.5) * multiplier
await asyncio.sleep(inter_delay)
except asyncio.CancelledError:
print(f'[{_ts()}] Message queue worker cancelled', flush=True)
break
except Exception as e:
print(f'[{_ts()}] Message queue worker error: {e}', flush=True)
await asyncio.sleep(1)
self._msg_worker_running = False
print(f'[{_ts()}] Message queue worker stopped', flush=True)
async def _send_coro(self, to, body, mtype): async def _send_coro(self, to, body, mtype):
try: try:
@ -496,7 +859,9 @@ class XMPPClient(ClientXMPP):
print(f'[{_ts()}] Typing delay: {delay:.1f}s ({len(body)} chars)', flush=True) print(f'[{_ts()}] Typing delay: {delay:.1f}s ({len(body)} chars)', flush=True)
await asyncio.sleep(delay) await asyncio.sleep(delay)
msg = self.make_message(mto=to, mbody=body, mtype=mtype) # Anti-ban: gunakan full JID untuk mfrom (server strict tentang ini)
mfrom = self.boundjid.full if self.boundjid else None
msg = self.make_message(mto=to, mbody=body, mtype=mtype, mfrom=mfrom)
msg.send() msg.send()
except Exception as e: except Exception as e:
print(f'[{_ts()}] SEND ERROR: {e}', flush=True) print(f'[{_ts()}] SEND ERROR: {e}', flush=True)
@ -508,12 +873,17 @@ class XMPPClient(ClientXMPP):
def start(self): def start(self):
print(f'[{_ts()}] Starting XMPP service...', flush=True) print(f'[{_ts()}] Starting XMPP service...', flush=True)
_setup_debug_logging()
asyncio.run(self._run()) asyncio.run(self._run())
async def _run(self): async def _run(self):
self._stopped = asyncio.Event() self._stopped = asyncio.Event()
self._loop = asyncio.get_running_loop() self._loop = asyncio.get_running_loop()
# Anti-ban: inisialisasi message queue dan worker
self._msg_queue = asyncio.Queue(maxsize=100)
self._msg_worker_task = asyncio.create_task(self._msg_worker())
# Hanya tangani SIGTERM untuk shutdown. # Hanya tangani SIGTERM untuk shutdown.
# SENGATKAN SIGHUP: nohup kirim SIGHUP saat terminal close, # SENGATKAN SIGHUP: nohup kirim SIGHUP saat terminal close,
# dan kita tidak mau proses mati karena itu. # dan kita tidak mau proses mati karena itu.
@ -522,7 +892,14 @@ class XMPPClient(ClientXMPP):
except (NotImplementedError, RuntimeError): except (NotImplementedError, RuntimeError):
pass pass
await self.connect() print(f'[{_ts()}] Connecting to server (jid={self.jid})...', flush=True)
try:
await self.connect()
except Exception as e:
print(f'[{_ts()}] CONNECT ERROR: {e}', flush=True)
print(traceback.format_exc(), flush=True)
raise
print(f'[{_ts()}] Connected, waiting for stream events...', flush=True)
try: try:
await self._stopped.wait() await self._stopped.wait()
except (asyncio.CancelledError, KeyboardInterrupt): except (asyncio.CancelledError, KeyboardInterrupt):
@ -535,4 +912,19 @@ class XMPPClient(ClientXMPP):
asyncio.run_coroutine_threadsafe(self._async_stop(), self._loop) asyncio.run_coroutine_threadsafe(self._async_stop(), self._loop)
async def _async_stop(self): async def _async_stop(self):
# Anti-ban: stop message queue worker dulu
if self._msg_worker_task and not self._msg_worker_task.done():
self._msg_worker_running = False
self._msg_worker_task.cancel()
try:
await self._msg_worker_task
except asyncio.CancelledError:
pass
# Cancel semua pending rejoin tasks
for room, task in list(self._muc_rejoin_tasks.items()):
if not task.done():
task.cancel()
self._muc_rejoin_tasks.clear()
self._stopped.set() self._stopped.set()