Send cvarlist and cmdlist on connect and merge the answer into the autocomplete candidates. Number the server menu from 0, keep suggesting sibling commands while the command name is typed, mask the password in the startup line, ignore venv/, fix README claims.
107 lines
3.8 KiB
Python
107 lines
3.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Cvar and command names learned from the server, for autocomplete.
|
|
|
|
After connecting, main.py sends `cvarlist` and `cmdlist`. The server answers
|
|
with one line per name and a count line at the end:
|
|
cvarlist: S A sv_hostname "My Server" ... 1234 total cvars
|
|
cmdlist: addbot ... 321 commands
|
|
NameCapture collects the names from those lines and hides the listing from
|
|
the output window while it is running.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
import time
|
|
|
|
logger = logging.getLogger('namelist')
|
|
|
|
# Capture stops when no listing output arrived for this many seconds.
|
|
# Guards against the count line looking different than expected, which would
|
|
# otherwise hide all later output forever. Measured from the last message, not
|
|
# from the request: a large cvarlist (5000+ messages) takes seconds to process.
|
|
CAPTURE_TIMEOUT = 5.0
|
|
|
|
# Cvar line: the name is the word directly before the quoted value.
|
|
# Example: 'S A sv_hostname "My Server"' -> sv_hostname
|
|
CVARLIST_LINE = re.compile(r'([A-Za-z_]\w*)\s+"')
|
|
|
|
# Command line: one bare word.
|
|
# Example: 'addbot' -> addbot
|
|
CMDLIST_LINE = re.compile(r'^\s*([A-Za-z_]\w*)\s*$')
|
|
|
|
# Count line that ends a listing.
|
|
# Examples: '1234 total cvars', '321 commands'
|
|
END_LINE = re.compile(r'^\s*\d+\s+(total cvars|commands)\s*$')
|
|
|
|
|
|
def unwrap(message):
|
|
"""
|
|
Turn one RCON message into plain text with real newlines.
|
|
Example: 'print "addbot\\n"' -> 'addbot\n'
|
|
"""
|
|
text = message
|
|
if text.startswith('print "'):
|
|
text = text[7:]
|
|
if text.endswith('"'):
|
|
text = text[:-1]
|
|
return text.replace('\\n', '\n')
|
|
|
|
|
|
class NameCapture:
|
|
"""Collects cvar and command names from the server's cvarlist and cmdlist output"""
|
|
|
|
def __init__(self):
|
|
self.names = set()
|
|
self.listings_pending = 0 # count lines still expected; 0 = not capturing
|
|
self.deadline = 0
|
|
self.partial_line = ''
|
|
|
|
def request(self, rcon):
|
|
"""Send cvarlist and cmdlist; their output is captured until both count lines arrived"""
|
|
self.listings_pending = 2
|
|
self.deadline = time.time() + CAPTURE_TIMEOUT
|
|
self.partial_line = ''
|
|
rcon.send_command(b'cvarlist')
|
|
rcon.send_command(b'cmdlist')
|
|
logger.info('Requested cvarlist and cmdlist for autocomplete')
|
|
|
|
def handle(self, message):
|
|
"""
|
|
Feed one RCON message.
|
|
Returns True when the message belongs to a running listing (hide it from the output).
|
|
"""
|
|
if self.listings_pending == 0:
|
|
return False
|
|
if time.time() > self.deadline:
|
|
logger.warning(f'No listing output for {CAPTURE_TIMEOUT:.0f}s, '
|
|
f'stopping capture with {len(self.names)} names')
|
|
self.listings_pending = 0
|
|
return False
|
|
self.deadline = time.time() + CAPTURE_TIMEOUT
|
|
|
|
# A line can arrive in several messages (the server sends each flag column of
|
|
# a cvarlist row as its own 1-byte message), so keep the unfinished tail
|
|
text = self.partial_line + unwrap(message)
|
|
lines = text.split('\n')
|
|
self.partial_line = lines.pop()
|
|
for line in lines:
|
|
self._learn_line(line)
|
|
|
|
# The last count line may come without a trailing newline
|
|
if END_LINE.match(self.partial_line):
|
|
self._learn_line(self.partial_line)
|
|
self.partial_line = ''
|
|
return True
|
|
|
|
def _learn_line(self, line):
|
|
if END_LINE.match(line):
|
|
self.listings_pending -= 1
|
|
if self.listings_pending == 0:
|
|
logger.info(f'Learned {len(self.names)} cvar and command names from the server')
|
|
return
|
|
|
|
match = CVARLIST_LINE.search(line) or CMDLIST_LINE.match(line)
|
|
if match:
|
|
self.names.add(match.group(1))
|