├── .gitignore ├── README.md ├── endpoint.lsl ├── lib.py ├── lslsh.py ├── modules ├── avinfo.lsl ├── avlist.lsl ├── echo.lsl ├── pkg.lsl ├── request.lsl ├── siminfo.lsl └── status_light.lsl ├── repository.lsl ├── requirements.txt └── todo.md /.gitignore: -------------------------------------------------------------------------------- 1 | .python-version 2 | .vimrc 3 | __pycache__/ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lsl-shell 2 | `lsl-shell` provides a simple Python-based shell for interacting with 3 | in-world API endpoints. 4 | 5 | **Note**: This project is still in an early development stage. Communication 6 | protocols will likely change. 7 | 8 | ## Getting started 9 | 1. Clone the repository 10 | 2. Copy the `endpoint.lsl` script contents and paste it in an in-world script 11 | 3. Add the script to a prim 12 | 4. Copy the emitted URL 13 | 5. Run `python lslsh.py` 14 | 6. Enter the URL: `connect https://sim[...].agni.lindenlab.com:12043/cap/[...]` 15 | 7. Type `help` for a list of available commands 16 | 17 | ## Use cases 18 | #### Interacting with other scripts 19 | You can directly interact with other scripts inside the endpoint object. 20 | This primarily happens via link messages. Responses from the scripts 21 | are returned to the shell. 22 | 23 | #### Rapid script development 24 | You can save a script and directly communicate with it through the endpoint. 25 | For example, you can work on a mathematical function and receive its output 26 | straight in your terminal. 27 | 28 | #### Administrative tasks 29 | For example: 30 | - Kicking and banning avatars 31 | - Retrieving sim usage statistics (which you can then easily process locally) 32 | - Remotely sending sim-wide messages 33 | 34 | #### HTTP proxy 35 | It's possible to use the endpoint as a HTTP proxy to visit websites or to 36 | make HTTP calls to other in-world objects. 37 | -------------------------------------------------------------------------------- /endpoint.lsl: -------------------------------------------------------------------------------- 1 | integer connected = 0; 2 | key SECRET_KEY = "29731e5170353a8b235098c43cd2099a4e805c55fb4395890e81f437c17334a9"; 3 | list commands = []; 4 | 5 | respond(key id, integer status, string key_, string data) 6 | { 7 | llHTTPResponse(id, status, llList2Json(JSON_OBJECT, [key_, data])); 8 | } 9 | 10 | broadcast_command(key request_id, string command) 11 | { 12 | // Broadcast the message to other scripts. We expect one script to return 13 | // a link_message in response. We pass the request_id to be able 14 | // to identify the response. 15 | llMessageLinked(LINK_SET, -1, command, request_id); 16 | } 17 | 18 | default 19 | { 20 | state_entry() 21 | { 22 | llRequestSecureURL(); 23 | llMessageLinked(LINK_SET, -1, "", "get_commands"); 24 | } 25 | 26 | link_message(integer link, integer num, string msg, key id) 27 | { 28 | if(num == -1 && id == "command_info") 29 | { 30 | commands += llParseString2List(msg, ["|"], []); 31 | } 32 | else if(num == 0) 33 | { 34 | respond(id, 200, "result", msg); 35 | } 36 | else if(num == 1) 37 | { 38 | respond(id, 200, "error", msg); 39 | } 40 | } 41 | 42 | changed(integer change) 43 | { 44 | if(change & CHANGED_OWNER || change & CHANGED_REGION || \ 45 | change & CHANGED_REGION_START) 46 | llResetScript(); 47 | } 48 | 49 | http_request(key id, string method, string body) 50 | { 51 | if(method == URL_REQUEST_GRANTED) 52 | { 53 | string url = body; 54 | llOwnerSay(url); 55 | } 56 | else if(method == "POST") 57 | { 58 | llOwnerSay("POST: " + body); 59 | if(llJsonGetValue(body, ["secret_key"]) == SECRET_KEY) 60 | { 61 | string command = llJsonGetValue(body, ["command"]); 62 | if(command == "connect") 63 | { 64 | if(connected == FALSE) 65 | { 66 | respond(id, 200, "uuid", llGetKey()); 67 | connected = TRUE; 68 | } 69 | else 70 | { 71 | respond(id, 423, "error", "Session is currently in use"); 72 | } 73 | } 74 | else if(command == "disconnect") 75 | { 76 | respond(id, 200, "result", "disconnected"); 77 | connected = FALSE; 78 | } 79 | else if(command == "get_commands") 80 | { 81 | respond(id, 200, "available_commands", llList2Json(JSON_OBJECT, \ 82 | commands)); 83 | } 84 | broadcast_command(id, command); 85 | } 86 | else 87 | { 88 | respond(id, 401, "error", "Invalid secret key"); 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib.py: -------------------------------------------------------------------------------- 1 | from json.decoder import JSONDecodeError 2 | from typing import Dict 3 | 4 | import requests 5 | 6 | 7 | class InvalidResponseError(Exception): 8 | pass 9 | 10 | 11 | class ErrorReceived(Exception): 12 | pass 13 | 14 | 15 | def send_cmd(url: str, secret_key: str, cmd: str) -> Dict: 16 | """Send a command to the endpoint and return the response.""" 17 | data = {"secret_key": secret_key, "command": cmd} 18 | 19 | try: 20 | response = requests.post(url, json=data, verify=False, timeout=5) 21 | response.raise_for_status() 22 | except requests.exceptions.HTTPError as e: 23 | code = e.response.status_code 24 | if code == 423 or code == 401: 25 | error_message = e.response.json().get("error") 26 | elif code == 504: 27 | error_message = e.response.content.decode("UTF-8") 28 | elif code == 404: 29 | error_message = "Endpoint URL not found" 30 | elif code == 500: 31 | error_message = "Internal SL server error" 32 | else: 33 | raise e 34 | 35 | e.args = (error_message,) 36 | raise e 37 | 38 | try: 39 | response_data = response.json() 40 | except JSONDecodeError as e: 41 | e.args = ("Response contains invalid json",) 42 | raise e 43 | 44 | error = response_data.get("error", None) 45 | if error: 46 | raise ErrorReceived(error) 47 | 48 | return response_data 49 | 50 | 51 | def connect(url: str, secret_key: str) -> str: 52 | """Connect to the given URL. 53 | 54 | Returns the UUID of the endpoint.""" 55 | uuid = send_cmd(url, secret_key, "connect").get("uuid", None) 56 | if not uuid: 57 | raise InvalidResponseError("Endpoint did not return its UUID") 58 | 59 | return uuid 60 | 61 | 62 | def disconnect(url: str, secret_key: str) -> bool: 63 | """Disconnect from the endpoint. 64 | 65 | Returns True if the endpoint responded with an acknlowledgement.""" 66 | try: 67 | result = send_cmd(url, secret_key, "disconnect").get("result", None) 68 | if result == "disconnected": 69 | return True 70 | except JSONDecodeError: 71 | pass 72 | 73 | return False 74 | 75 | 76 | def get_available_commands(url: str, secret_key: str) -> Dict: 77 | """Get a list of available commands from the endpoint.""" 78 | cmds = send_cmd(url, secret_key, "get_commands").get("available_commands", None) 79 | if not cmds: 80 | raise InvalidResponseError("Endpoint did not return command list") 81 | 82 | return cmds 83 | -------------------------------------------------------------------------------- /lslsh.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import cmd 4 | import readline 5 | import sys 6 | import warnings 7 | from json.decoder import JSONDecodeError 8 | from typing import Dict, List, Union 9 | 10 | import requests 11 | from colorama import Back, Fore, Style, deinit, init # type: ignore 12 | from tabulate import tabulate 13 | from urllib3.connectionpool import InsecureRequestWarning # type: ignore 14 | 15 | from lib import ErrorReceived, connect, disconnect, get_available_commands, send_cmd 16 | 17 | SECRET_KEY: str = "29731e5170353a8b235098c43cd2099a4e805c55fb4395890e81f437c17334a9" 18 | INTRO_TEXT: str = 'lslsh 0.0.1\nType "help" for more information.' 19 | 20 | warnings.filterwarnings("ignore", category=InsecureRequestWarning) 21 | 22 | 23 | class Shell(cmd.Cmd): 24 | prompt = "> " 25 | default_prompt = "> " 26 | url = None 27 | ruler = "-" 28 | doc_header = "Available built-in commands (type help ):" 29 | undoc_header = "Undocumented built-in commands:" 30 | doc_remote_header = "Available endpoint commands (type help ):" 31 | undoc_remote_header = "Undocumented endpoint commands:" 32 | remote_commands: List[str] = [] 33 | 34 | def get_names(self): 35 | return dir(self) 36 | 37 | def precmd(self, line): 38 | if line == "EOF": 39 | print() 40 | return "disconnect" if self.url else "exit" 41 | 42 | return line 43 | 44 | def emptyline(self): 45 | return None 46 | 47 | def pretty_print(self, data: Union[str, Dict, List]) -> Union[str, Dict, List]: 48 | """Attempt to pretty-print the input data.""" 49 | try: 50 | if isinstance(data, list) and isinstance(data[0], dict): 51 | # List of dicts; no header 52 | rows = [] 53 | for item in data: 54 | rows.append(list(item.keys()) + list(item.values())) 55 | return tabulate(rows, tablefmt="plain") 56 | elif isinstance(data, list) and isinstance(data[0], list): 57 | # List of lists; use first row as header 58 | 59 | # Style the header items 60 | for i, item in enumerate(data[0]): 61 | data[0][i] = f"\033[4m{Style.BRIGHT}{item}{Style.NORMAL}\033[0m" 62 | 63 | return tabulate(data, headers="firstrow", tablefmt="plain") 64 | elif isinstance(data, dict) and len(data.values()) == 1: 65 | # Dict with one list; return a single column with header 66 | header: str = next(iter(data.keys())) 67 | values: list = next(iter(data.values())) 68 | result = [f"\033[4m{Style.BRIGHT}{header}{Style.NORMAL}\033[0m"] 69 | result.extend(values) 70 | return "\n".join(result) 71 | except TypeError: 72 | pass 73 | 74 | return data 75 | 76 | def _send_cmd(self, command: str) -> str: 77 | if not self.url: 78 | return f"{Fore.RED}Error{Fore.RESET}: Not connected to an endpoint." 79 | 80 | try: 81 | result = send_cmd(self.url, SECRET_KEY, command).get("result") 82 | except requests.exceptions.HTTPError as e: 83 | if e.response.status_code == 404: 84 | print(f"{Fore.RED}Error{Fore.RESET}: Endpoint no longer available.") 85 | self.do_disconnect(None) 86 | return "" 87 | else: 88 | return f"{Fore.RED}Error{Fore.RESET}: {e}" 89 | except Exception as e: 90 | return f"{Fore.RED}Error{Fore.RESET}: {e}" 91 | 92 | return self.pretty_print(result) 93 | 94 | def do_connect(self, url): 95 | """usage: connect [URL] 96 | 97 | Connect to the given endpoint URL. 98 | """ 99 | if self.url: 100 | self.do_disconnect(None) 101 | 102 | try: 103 | uuid = connect(url, SECRET_KEY) 104 | except Exception as e: 105 | print(f"{Fore.RED}Connection failed{Fore.RESET}: {e}") 106 | return 107 | 108 | available_commands = get_available_commands(url, SECRET_KEY) 109 | for key, value in available_commands.items(): 110 | self.add_cmd(key, value) 111 | 112 | print(f"{Fore.GREEN}Connected to {uuid}{Fore.RESET}\n") 113 | self.prompt = f"{Fore.BLUE}sl{Fore.RESET} > " 114 | self.url = url 115 | 116 | def do_exit(self, arg): 117 | """usage: exit 118 | 119 | Exit the shell. 120 | """ 121 | if self.url: 122 | self.do_disconnect(None) 123 | 124 | return True 125 | 126 | def do_disconnect(self, arg): 127 | """usage: disconnect 128 | 129 | Disconnect from the endpoint.""" 130 | if self.url: 131 | success = False 132 | try: 133 | success = disconnect(self.url, SECRET_KEY) 134 | print("Disconnected from endpoint.") 135 | except Exception: 136 | pass 137 | 138 | if not success: 139 | print("Disconnected from endpoint (without acknowledgement).") 140 | 141 | self.url = None 142 | self.prompt = self.default_prompt 143 | for cmd in self.remote_commands: 144 | self.remove_cmd(cmd) 145 | else: 146 | print(f"{Fore.RED}Error{Fore.RESET}: Not connected to endpoint") 147 | 148 | def add_cmd(self, name, help_text): 149 | """Make a new command available within the shell.""" 150 | 151 | def do_cmd(arg): 152 | print(self._send_cmd(f"{do_cmd.__name__} {arg}")) 153 | 154 | do_cmd.__doc__ = help_text 155 | do_cmd.__name__ = name 156 | 157 | setattr(self, f"do_{name}", do_cmd) 158 | self.remote_commands.append(name) 159 | 160 | def remove_cmd(self, name): 161 | """Remove a command from the shell.""" 162 | 163 | if not hasattr(Shell, f"do_{name}") and hasattr(self, f"do_{name}"): 164 | delattr(self, f"do_{name}") 165 | filter(lambda a: a != name, self.remote_commands) 166 | 167 | def do_help(self, arg): 168 | """List available commands with "help" or detailed help with "help cmd".""" 169 | if arg: 170 | try: 171 | func = getattr(self, "help_" + arg) 172 | except AttributeError: 173 | try: 174 | doc = getattr(self, "do_" + arg).__doc__ 175 | if doc: 176 | stripped_lines = [] 177 | for line in doc.splitlines(): 178 | stripped_lines.append(line.strip()) 179 | 180 | if stripped_lines[-1] != "": 181 | stripped_lines.append("") 182 | 183 | stripped = "\n".join(stripped_lines) 184 | self.stdout.write(f"{stripped}\n") 185 | return 186 | except AttributeError: 187 | pass 188 | self.stdout.write("%s\n" % str(self.nohelp % (arg,))) 189 | return 190 | func() 191 | else: 192 | names = self.get_names() 193 | cmds_doc = [] 194 | cmds_undoc = [] 195 | cmds_doc_remote = [] 196 | cmds_undoc_remote = [] 197 | help = {} 198 | for name in names: 199 | if name[:5] == "help_": 200 | help[name[5:]] = 1 201 | 202 | names.sort() 203 | # There can be duplicates if routines overridden 204 | prevname = "" 205 | for name in names: 206 | if name[:3] == "do_": 207 | if name == prevname: 208 | continue 209 | prevname = name 210 | cmd = name[3:] 211 | if cmd in self.remote_commands: 212 | if cmd in help: 213 | cmds_undoc_remote.append(cmd) 214 | else: 215 | cmds_doc_remote.append(cmd) 216 | elif cmd in help: 217 | cmds_doc.append(cmd) 218 | del help[cmd] 219 | elif getattr(self, name).__doc__: 220 | cmds_doc.append(cmd) 221 | else: 222 | cmds_undoc.append(cmd) 223 | 224 | self.stdout.write("%s\n" % str(self.doc_leader)) 225 | self.print_topics(self.doc_header, cmds_doc, 15, 80) 226 | self.print_topics(self.doc_remote_header, cmds_doc_remote, 15, 80) 227 | self.print_topics(self.misc_header, list(help.keys()), 15, 80) 228 | self.print_topics(self.undoc_header, cmds_undoc, 15, 80) 229 | self.print_topics(self.undoc_remote_header, cmds_undoc_remote, 15, 80) 230 | 231 | def print_topics(self, header, cmds, cmdlen, maxcol): 232 | if cmds: 233 | self.stdout.write(Style.BRIGHT + "%s\n" % str(header)) 234 | if self.ruler: 235 | self.stdout.write( 236 | Fore.LIGHTBLACK_EX + "%s\n" % str(self.ruler * len(header)) 237 | ) 238 | self.columnize(cmds, maxcol - 1) 239 | self.stdout.write("\n") 240 | 241 | 242 | def run(): 243 | init(autoreset=True) 244 | shell = Shell() 245 | try: 246 | shell.cmdloop(INTRO_TEXT) 247 | except KeyboardInterrupt: 248 | shell.do_exit(None) 249 | deinit() 250 | except Exception: 251 | deinit() 252 | # Attempt to disconnect so the session immediately becomes available again 253 | try: 254 | shell.do_disconnect() 255 | except Exception: 256 | pass 257 | 258 | raise 259 | 260 | 261 | run() 262 | -------------------------------------------------------------------------------- /modules/avinfo.lsl: -------------------------------------------------------------------------------- 1 | string COMMAND = "avinfo"; 2 | string USAGE = \ 3 | "usage: avinfo [uuid] [-h] 4 | 5 | Request info about avatar with the given UUID. 6 | 7 | positional arguments: 8 | uuid uuid of avatar 9 | 10 | optional arguments: 11 | -h, --help show help and exit 12 | 13 | ex: avinfo c53bf932-81ea-4a9f-b464-c4b545f93db1 14 | "; 15 | 16 | string avinfo(list params) 17 | { 18 | // Display help 19 | if(llListFindList(params, ["-h"]) != -1 || 20 | llListFindList(params, ["--help"]) != -1 || 21 | llGetListLength(params) == 0) 22 | { 23 | return USAGE; 24 | } 25 | 26 | key id = llList2Key(params, 0); 27 | 28 | list rows = []; 29 | 30 | list details = llGetObjectDetails(id, [ 31 | OBJECT_RENDER_WEIGHT, OBJECT_STREAMING_COST, 32 | OBJECT_HOVER_HEIGHT, OBJECT_BODY_SHAPE_TYPE, OBJECT_GROUP_TAG, 33 | OBJECT_ATTACHED_SLOTS_AVAILABLE, 34 | OBJECT_RUNNING_SCRIPT_COUNT, OBJECT_TOTAL_SCRIPT_COUNT, 35 | OBJECT_SCRIPT_MEMORY, OBJECT_SCRIPT_TIME]); 36 | 37 | rows += llList2Json(JSON_OBJECT, ["display name", llGetDisplayName(id)]); 38 | rows += llList2Json(JSON_OBJECT, ["username", llGetUsername(id)]); 39 | rows += llList2Json(JSON_OBJECT, ["language", llGetAgentLanguage(id)]); 40 | 41 | string group_tag = llList2String(details, 4); 42 | if(group_tag == "") group_tag = "none"; 43 | rows += llList2Json(JSON_OBJECT, ["group tag", group_tag]); 44 | 45 | float hover_height = llList2Float(details, 2); 46 | float body_shape = llList2Float(details, 3); 47 | rows += llList2Json(JSON_OBJECT, ["hover height", (string)hover_height]); 48 | rows += llList2Json(JSON_OBJECT, ["body shape", (string)body_shape]); 49 | rows += llList2Json(JSON_OBJECT, ["size", (string)llGetAgentSize(id)]); 50 | 51 | rows += llList2Json(JSON_OBJECT, ["attached slots", llList2String(details, 5)]); 52 | 53 | float streaming_cost = llList2Float(details, 1); 54 | string render_weight = llList2String(details, 0); 55 | rows += llList2Json(JSON_OBJECT, ["streaming cost", (string)streaming_cost]); 56 | rows += llList2Json(JSON_OBJECT, ["render weight", (string)render_weight]); 57 | 58 | string running_scripts = llList2String(details, 6); 59 | string total_scripts = llList2String(details, 7); 60 | string script_memory = (string)llRound(llList2Float(details, 8) / 1024); 61 | float script_time = llList2Float(details, 9); 62 | rows += llList2Json(JSON_OBJECT, ["script count", running_scripts + " / " + total_scripts]); 63 | rows += llList2Json(JSON_OBJECT, ["script memory", script_memory + " kb"]); 64 | rows += llList2Json(JSON_OBJECT, ["script time", (string)((integer)((script_time*1000000))) + " μs"]); 65 | 66 | return llList2Json(JSON_ARRAY, rows); 67 | } 68 | 69 | default 70 | { 71 | link_message(integer sender, integer num, string msg, key id) 72 | { 73 | list params = llParseString2List(msg, [" "], [""]); 74 | string param0 = llList2String(params, 0); 75 | string param1 = llList2String(params, 1); 76 | 77 | if(id == "get_commands") 78 | { 79 | llMessageLinked(LINK_SET, -1, COMMAND + "|" + USAGE, "command_info"); 80 | } 81 | else if(param0 == COMMAND) 82 | { 83 | string response = avinfo(llDeleteSubList(params, 0, 0)); 84 | llMessageLinked(LINK_SET, 0, response, id); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /modules/avlist.lsl: -------------------------------------------------------------------------------- 1 | string COMMAND = "avlist"; 2 | string USAGE = \ 3 | "usage: avlist [-h] [-s] [-r] [-k] 4 | 5 | Request info about all avatars in the current region. 6 | 7 | optional arguments: 8 | -s get script info 9 | -r get render info 10 | -k get key 11 | -h, --help show help and exit 12 | "; 13 | 14 | string avlist(list params) 15 | { 16 | // Display help 17 | if(llListFindList(params, ["-h"]) != -1 || 18 | llListFindList(params, ["--help"]) != -1) 19 | { 20 | return USAGE; 21 | } 22 | 23 | integer options_mask; 24 | list headers = ["Name"]; 25 | if(llListFindList(params, ["-k"]) != -1) // option: key 26 | { 27 | options_mask += 1; 28 | headers += ["Key"]; 29 | } 30 | if(llListFindList(params, ["-s"]) != -1) // option: script info 31 | { 32 | options_mask += 2; 33 | headers += ["Scripts", "Mem (kb)", "Time (μs)"]; 34 | } 35 | if(llListFindList(params, ["-r"]) != -1) // option: render info 36 | { 37 | options_mask += 4; 38 | headers += ["Streaming cost"]; 39 | } 40 | 41 | list agents = llGetAgentList(AGENT_LIST_REGION, []); 42 | integer agent_count = llGetListLength(agents); 43 | list rows = [llList2Json(JSON_ARRAY, headers)]; 44 | integer i; 45 | for(i=0; i < agent_count; i++) 46 | { 47 | key agent_key = llList2Key(agents, i); 48 | string agent_name = llKey2Name(agent_key); 49 | list agent_info = [agent_name]; 50 | 51 | // [-k] option: show key 52 | if(options_mask & 1) 53 | { 54 | agent_info += [agent_key]; 55 | } 56 | 57 | // [-s] option: script info 58 | if(options_mask & 2) 59 | { 60 | list object_details = llGetObjectDetails(agent_key, [ 61 | OBJECT_RUNNING_SCRIPT_COUNT, OBJECT_TOTAL_SCRIPT_COUNT, 62 | OBJECT_SCRIPT_MEMORY, OBJECT_SCRIPT_TIME]); 63 | integer running_scripts = llList2Integer(object_details, 0); 64 | integer total_scripts = llList2Integer(object_details, 1); 65 | integer script_memory = llRound(llList2Float(object_details, 2) / 1024); 66 | float script_time = llList2Float(object_details, 3); 67 | 68 | agent_info += [(string)running_scripts + " / " + (string)total_scripts]; 69 | agent_info += [(string)script_memory]; 70 | agent_info += [(string)((integer)((script_time*1000000)))]; 71 | } 72 | 73 | // [-r] option: render info 74 | if(options_mask & 4) 75 | { 76 | float streaming_cost = llList2Float(llGetObjectDetails(agent_key, [OBJECT_STREAMING_COST]), 0); 77 | agent_info += [streaming_cost]; 78 | } 79 | rows += llList2Json(JSON_ARRAY, agent_info); 80 | } 81 | return llList2Json(JSON_ARRAY, rows); 82 | } 83 | 84 | default 85 | { 86 | link_message(integer sender, integer num, string msg, key id) 87 | { 88 | list params = llParseString2List(msg, [" "], [""]); 89 | string param0 = llList2String(params, 0); 90 | 91 | if(id == "get_commands") 92 | { 93 | llMessageLinked(LINK_SET, -1, COMMAND + "|" + USAGE, "command_info"); 94 | } 95 | else if(param0 == COMMAND) 96 | { 97 | string response = avlist(llDeleteSubList(params, 0, 0)); 98 | llMessageLinked(LINK_SET, 0, response, id); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /modules/echo.lsl: -------------------------------------------------------------------------------- 1 | string COMMAND = "echo"; 2 | string USAGE = \ 3 | "usage: echo [arg ...] 4 | 5 | Repeat the given arguments. 6 | "; 7 | 8 | default 9 | { 10 | link_message(integer sender, integer num, string msg, key id) 11 | { 12 | list params = llParseString2List(msg, [" "], [""]); 13 | string param0 = llList2String(params, 0); 14 | 15 | if(id == "get_commands") 16 | { 17 | llMessageLinked(LINK_SET, -1, COMMAND + "|" + USAGE, "command_info"); 18 | } 19 | else if(param0 == COMMAND) 20 | { 21 | string response = llDumpList2String(llDeleteSubList(params, 0, 0), " "); 22 | llMessageLinked(LINK_SET, 0, response, id); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /modules/pkg.lsl: -------------------------------------------------------------------------------- 1 | integer CHANNEL = -54321; 2 | string ENDPOINT_SCRIPT_NAME = "endpoint.lsl"; 3 | string COMMAND = "pkg"; 4 | string USAGE = \ 5 | "usage: pkg [-h] command [args...] 6 | 7 | Manage modules. Installing modules requires a nearby repository. 8 | 9 | arguments: 10 | install MODULE install MODULE from nearby repository 11 | remove MODULE remove MODULE 12 | enable MODULE enable MODULE 13 | disable MODULE disable MODULE 14 | query query installable modules 15 | list list currently installed modules 16 | 17 | optional arguments: 18 | -h, --help show help and exit 19 | "; 20 | 21 | integer listen_handle; 22 | string installing_module; 23 | key command_request_id; 24 | list old_modules; 25 | 26 | string pkg(list params) 27 | { 28 | /* Parse the param list to call sub-commands depending on user input. 29 | 30 | Return the output of the sub-command. 31 | On error or invalid input, return a JSON object with a short descriptive message. 32 | */ 33 | 34 | // Display help 35 | if(llListFindList(params, ["-h"]) != -1 || 36 | llListFindList(params, ["--help"]) != -1 || 37 | llGetListLength(params) == 0) 38 | { 39 | return USAGE; 40 | } 41 | 42 | string cmd = llList2String(params, 0); 43 | 44 | if(cmd == "query") return query_modules(); 45 | else if(cmd == "list") return list_installed_modules(); 46 | 47 | // Parse module param; check for errors and append '.lsl' 48 | string module = llList2String(params, 1); 49 | if(module == "") return llList2Json(JSON_OBJECT, ["error", "Missing argument."]); 50 | if(llGetSubString(module, -4, -1) != ".lsl") module = module + ".lsl"; 51 | 52 | if(cmd == "install") return install_module(module); 53 | 54 | // Return error if module does not exist 55 | integer exists = llListFindList(get_installed_modules(), [module]); 56 | if(exists == -1) return llList2Json(JSON_OBJECT, ["error", "Module is not installed."]); 57 | 58 | if(cmd == "remove") return remove_module(module); 59 | else if(cmd == "enable") return enable_module(module); 60 | else if(cmd == "disable") return disable_module(module); 61 | 62 | return llList2Json(JSON_OBJECT, ["error", "Invalid command."]); 63 | } 64 | 65 | string install_module(string module) 66 | { 67 | /* Start the install process for the given module. 68 | 69 | Send a request to a repository in the sim and wait for a response. 70 | */ 71 | installing_module = module; 72 | old_modules = get_installed_modules(); 73 | listen_handle = llListen(CHANNEL, "", "", ""); 74 | string data = llList2Json(JSON_OBJECT, ["command", "request", "module", module]); 75 | llRegionSay(CHANNEL, data); 76 | return "AWAIT"; 77 | } 78 | 79 | string remove_module(string module) 80 | { 81 | /* Remove the given module from our inventory. 82 | */ 83 | llRemoveInventory(module); 84 | string name = llGetSubString(module, 0, -5); 85 | return "Module '" + name + "' removed."; 86 | } 87 | 88 | string enable_module(string module) 89 | { 90 | /* Set the script state of the module to TRUE if not already enabled. 91 | */ 92 | string name = llGetSubString(module, 0, -5); 93 | 94 | if(llGetScriptState(module) == FALSE) 95 | { 96 | llSetScriptState(module, TRUE); 97 | return "Module '" + name + "' enabled."; 98 | } 99 | 100 | return llList2Json(JSON_OBJECT, ["error", "Module '" + name + "' is already enabled."]); 101 | } 102 | 103 | string disable_module(string module) 104 | { 105 | /* Set the script state of the module to FALSE if not already disabled. 106 | */ 107 | string name = llGetSubString(module, 0, -5); 108 | 109 | if(llGetScriptState(module) == TRUE) 110 | { 111 | llSetScriptState(module, FALSE); 112 | return "Module '" + name + "' disabled."; 113 | } 114 | 115 | return llList2Json(JSON_OBJECT, ["error", "Module '" + name + "' is already disabled."]); 116 | } 117 | 118 | string query_modules() 119 | { 120 | /* Broadcast a message with a query for available modules. 121 | */ 122 | listen_handle = llListen(CHANNEL, "", "", ""); 123 | string data = llList2Json(JSON_OBJECT, ["command", "query"]); 124 | llRegionSay(CHANNEL, data); 125 | llSetTimerEvent(3); 126 | return "AWAIT"; 127 | } 128 | 129 | string list_installed_modules() 130 | { 131 | /* Return a JSON array with installed modules. 132 | */ 133 | list rows = [llList2Json(JSON_ARRAY, ["Name", "Version", "Enabled"])]; 134 | list modules = get_installed_modules(); 135 | integer count = llGetListLength(modules); 136 | while(count--) 137 | { 138 | // TODO Get version number 139 | string name = llList2String(modules, count); 140 | string enabled = "Yes"; 141 | if(llGetScriptState(name) == FALSE) enabled = "No"; 142 | name = llGetSubString(name, 0, -5); 143 | rows += [llList2Json(JSON_ARRAY, [name, "unknown", enabled])]; 144 | } 145 | return llList2Json(JSON_ARRAY, rows); 146 | } 147 | 148 | list get_installed_modules() 149 | { 150 | /* Return a list of installed modules. 151 | */ 152 | list modules; 153 | integer count = llGetInventoryNumber(INVENTORY_SCRIPT); 154 | while(count--) 155 | { 156 | string name = llGetInventoryName(INVENTORY_SCRIPT, count); 157 | if(name != llGetScriptName() && name != ENDPOINT_SCRIPT_NAME) 158 | { 159 | string extension = llGetSubString(name, -4, -1); 160 | if(extension == ".lsl") modules += [name]; 161 | } 162 | } 163 | return modules; 164 | } 165 | 166 | list list_x_not_y(list lx, list ly) 167 | { 168 | /* Return a list of items that are in list X, but not in list Y. 169 | 170 | Source: http://wiki.secondlife.com/wiki/ListXnotY 171 | */ 172 | list lz; 173 | integer i = llGetListLength(lx); 174 | while(i--) 175 | { 176 | if(!~llListFindList(ly,llList2List(lx,i,i))) 177 | { 178 | lz += llList2List(lx,i,i); 179 | } 180 | } 181 | return lz; 182 | } 183 | 184 | string handle_inventory_change() 185 | { 186 | /* Examine the inventory change and return an appropriate response. 187 | */ 188 | string response; 189 | string name = llGetSubString(installing_module, 0, -5); 190 | list new_modules = get_installed_modules(); 191 | if(llGetListLength(new_modules) == llGetListLength(old_modules)) 192 | { 193 | response = "Module '" + name + "' reinstalled."; 194 | } 195 | else 196 | { 197 | // Ensure the correct script was added 198 | list diff = list_x_not_y(get_installed_modules(), old_modules); 199 | if(llList2String(diff, 0) == installing_module) 200 | { 201 | response = "Module '" + name + "' installed."; 202 | } 203 | else 204 | { 205 | response = llList2Json(JSON_OBJECT, ["error", "Warning: Unexpected module '" + installing_module + "' was installed!"]); 206 | } 207 | } 208 | 209 | llSetRemoteScriptAccessPin(0); 210 | llListenRemove(listen_handle); 211 | installing_module = ""; 212 | 213 | return response; 214 | } 215 | 216 | respond(integer code, string data) 217 | { 218 | /* Send a response to the endpoint. 219 | 220 | If error_msg is not empty, it will be sent instead of data. 221 | */ 222 | key id = command_request_id; 223 | if(code == -1) id = "command_info"; 224 | 225 | llMessageLinked(LINK_SET, code, data, id); 226 | } 227 | 228 | default 229 | { 230 | link_message(integer sender, integer num, string msg, key id) 231 | { 232 | list params = llParseString2List(msg, [" "], [""]); 233 | string param0 = llList2String(params, 0); 234 | 235 | if(id == "get_commands") 236 | { 237 | respond(-1, COMMAND + "|" + USAGE); 238 | } 239 | else if(param0 == COMMAND) 240 | { 241 | command_request_id = id; 242 | string result = pkg(llDeleteSubList(params, 0, 0)); 243 | if(result != "AWAIT") 244 | { 245 | string error = llJsonGetValue(result, ["error"]); 246 | if(error != JSON_INVALID) 247 | { 248 | respond(1, error); 249 | return; 250 | } 251 | respond(0, result); 252 | } 253 | } 254 | } 255 | 256 | listen(integer channel, string name, key id, string data) 257 | { 258 | // Return the error message if the repository raised one 259 | string error = llJsonGetValue(data, ["error"]); 260 | if(error != JSON_INVALID) 261 | { 262 | respond(1, error); 263 | return; 264 | } 265 | 266 | // Return the module listing if the repository returned it 267 | string modules = llJsonGetValue(data, ["modules"]); 268 | if(modules != JSON_INVALID) 269 | { 270 | string response = llList2Json(JSON_OBJECT, ["Modules"] + (list)modules); 271 | respond(0, response); 272 | return; 273 | } 274 | 275 | // Return an error message if the module is not available in the repository 276 | integer available = (integer)llJsonGetValue(data, ["available"]); 277 | if(!available) 278 | { 279 | respond(1, "Module not available in repository."); 280 | return; 281 | } 282 | 283 | // Set the script access PIN that was returned by the repository 284 | // in order to accept the new module 285 | integer pin = (integer)llJsonGetValue(data, ["pin"]); 286 | llSetRemoteScriptAccessPin(pin); 287 | 288 | // Ask the repository to send the new module 289 | string response = llList2Json(JSON_OBJECT, ["command", "send", 290 | "module", installing_module]); 291 | llRegionSayTo(id, channel, response); 292 | } 293 | 294 | changed(integer change) 295 | { 296 | if(change & CHANGED_INVENTORY && installing_module != "") 297 | { 298 | respond(0, handle_inventory_change()); 299 | } 300 | } 301 | 302 | timer() 303 | { 304 | respond(1, "No repository nearby."); 305 | llSetTimerEvent(0); 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /modules/request.lsl: -------------------------------------------------------------------------------- 1 | string COMMAND = "request"; 2 | string USAGE = \ 3 | "usage: request url [-h] [-m] [-d] 4 | 5 | Do a HTTP request. 6 | 7 | positional arguments: 8 | url URL to use 9 | 10 | optional arguments: 11 | -m method to use (default = GET) 12 | -d data to send 13 | -h, --help show help and exit 14 | "; 15 | 16 | key command_request_id; 17 | key http_request_id; 18 | 19 | 20 | string request(list params) 21 | { 22 | /* Do a HTTP request. 23 | Return a string with a message if errors occur. 24 | */ 25 | 26 | // Display help 27 | if(llListFindList(params, ["-h"]) != -1 || 28 | llListFindList(params, ["--help"]) != -1 || 29 | !llGetListLength(params)) 30 | { 31 | return USAGE; 32 | } 33 | 34 | integer options_mask; 35 | string method = "GET"; 36 | string data = ""; 37 | string url = llList2String(params, 0); 38 | 39 | // Strip surrounding quotes from url if present 40 | if(llGetSubString(url, 0, 0) == "\"" && llGetSubString(url, -1, -1) == "\"") 41 | { 42 | url = llGetSubString(url, 1, -2); 43 | } 44 | 45 | // Prepend with http:// if no protocol was specified 46 | if(llGetSubString(url, 0, 3) != "http") 47 | { 48 | url = "http:\/\/" + url; 49 | } 50 | 51 | // Handle method option 52 | integer opt_m = llListFindList(params, ["-m"]); 53 | if(opt_m != -1) 54 | { 55 | method = llList2String(params, opt_m + 1); 56 | } 57 | 58 | // Handle data option 59 | integer opt_d = llListFindList(params, ["-d"]); 60 | if(opt_d != -1) 61 | { 62 | data = llList2String(params, opt_d + 1); 63 | } 64 | 65 | list parameters = [ 66 | HTTP_METHOD, method 67 | ]; 68 | 69 | http_request_id = llHTTPRequest(url, parameters, data); 70 | 71 | if(http_request_id == NULL_KEY) 72 | { 73 | return llList2Json(JSON_OBJECT, ["error", "URL passed to llHTTPRequest is not valid"]); 74 | } 75 | 76 | return ""; 77 | } 78 | 79 | default 80 | { 81 | link_message(integer sender, integer num, string msg, key id) 82 | { 83 | list params = llParseString2List(msg, [" "], [""]); 84 | string param0 = llList2String(params, 0); 85 | 86 | if(id == "get_commands") 87 | { 88 | llMessageLinked(LINK_SET, -1, COMMAND + "|" + USAGE, "command_info"); 89 | } 90 | else if(param0 == COMMAND) 91 | { 92 | command_request_id = id; 93 | string errors = request(llDeleteSubList(params, 0, 0)); 94 | if(errors != "") 95 | { 96 | llMessageLinked(LINK_SET, 1, errors, command_request_id); 97 | } 98 | } 99 | } 100 | 101 | http_response(key id, integer status, list metadata, string body) 102 | { 103 | if(id != http_request_id) return; 104 | 105 | llMessageLinked(LINK_SET, 0, body, command_request_id); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /modules/siminfo.lsl: -------------------------------------------------------------------------------- 1 | string COMMAND = "siminfo"; 2 | string USAGE = \ 3 | "usage: siminfo 4 | 5 | Request simulator details. 6 | "; 7 | 8 | default 9 | { 10 | link_message(integer sender, integer num, string msg, key id) 11 | { 12 | list params = llParseString2List(msg, [" "], [""]); 13 | string param0 = llList2String(params, 0); 14 | 15 | if(id == "get_commands") 16 | { 17 | llMessageLinked(LINK_SET, -1, COMMAND + "|" + USAGE, "command_info"); 18 | } 19 | else if(param0 == COMMAND) 20 | { 21 | list rows = [ 22 | llList2Json(JSON_OBJECT, ["agent_limit", llGetEnv("agent_limit")]), 23 | llList2Json(JSON_OBJECT, ["dynamic_pathfinding", llGetEnv("dynamic_pathfinding")]), 24 | llList2Json(JSON_OBJECT, ["estate_id", llGetEnv("estate_id")]), 25 | llList2Json(JSON_OBJECT, ["estate_name", llGetEnv("estate_name")]), 26 | llList2Json(JSON_OBJECT, ["frame_number", llGetEnv("frame_number")]), 27 | llList2Json(JSON_OBJECT, ["region_cpu_ratio", llGetEnv("region_cpu_ratio")]), 28 | llList2Json(JSON_OBJECT, ["region_idle", llGetEnv("region_idle")]), 29 | llList2Json(JSON_OBJECT, ["region_product_name", llGetEnv("region_product_name")]), 30 | llList2Json(JSON_OBJECT, ["region_product_sku", llGetEnv("region_product_sku")]), 31 | llList2Json(JSON_OBJECT, ["region_start_time", llGetEnv("region_start_time")]), 32 | llList2Json(JSON_OBJECT, ["sim_channel", llGetEnv("sim_channel")]), 33 | llList2Json(JSON_OBJECT, ["sim_version", llGetEnv("sim_version")]), 34 | llList2Json(JSON_OBJECT, ["simulator_hostname", llGetEnv("simulator_hostname")]), 35 | llList2Json(JSON_OBJECT, ["region_max_prims", llGetEnv("region_max_prims")]), 36 | llList2Json(JSON_OBJECT, ["region_object_bonus", llGetEnv("region_object_bonus")]) 37 | ]; 38 | 39 | string response = llList2Json(JSON_ARRAY, rows); 40 | 41 | llMessageLinked(LINK_SET, 0, response, id); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /modules/status_light.lsl: -------------------------------------------------------------------------------- 1 | integer connected = FALSE; 2 | 3 | blink() 4 | { 5 | llSetColor(<0,0,0>, ALL_SIDES); 6 | llSleep(.05); 7 | if(connected) llSetColor(<0,1,0>, ALL_SIDES); 8 | else llSetColor(<0,0,0>, ALL_SIDES); 9 | } 10 | 11 | default 12 | { 13 | link_message(integer sender, integer num, string msg, key id) 14 | { 15 | list params = llParseString2List(msg, [" "], [""]); 16 | string param0 = llList2String(params, 0); 17 | 18 | if(param0 == "connect") 19 | { 20 | connected = TRUE; 21 | } 22 | else if(param0 == "disconnect") 23 | { 24 | connected = FALSE; 25 | } 26 | 27 | blink(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /repository.lsl: -------------------------------------------------------------------------------- 1 | integer CHANNEL = -54321; 2 | 3 | integer pin; 4 | regenerate_pin() 5 | { 6 | /* Generate a pseudo-random number to use as a PIN. */ 7 | string md5 = "seed"; 8 | integer count = 10; 9 | while(count--) 10 | { 11 | md5 = llMD5String(md5 + (string)llGetUnixTime() + (string)llGetTime(), 0x5EED); 12 | } 13 | pin = (integer)("0x" + llGetSubString(md5, 0, 7)); 14 | } 15 | 16 | integer module_is_available(string module) 17 | { 18 | list modules; 19 | integer count = llGetInventoryNumber(INVENTORY_SCRIPT); 20 | while(count--) 21 | { 22 | string name = llGetInventoryName(INVENTORY_SCRIPT, count); 23 | if(name != llGetScriptName()) 24 | { 25 | string extension = llGetSubString(name, -4, -1); 26 | if(extension == ".lsl") 27 | { 28 | modules += [name]; 29 | } 30 | } 31 | } 32 | 33 | if(llListFindList(modules, [module]) == -1) return FALSE; 34 | else return TRUE; 35 | } 36 | 37 | list get_modules() 38 | { 39 | /* Return a list of available modules. 40 | */ 41 | list modules; 42 | integer count = llGetInventoryNumber(INVENTORY_SCRIPT); 43 | while(count--) 44 | { 45 | string name = llGetInventoryName(INVENTORY_SCRIPT, count); 46 | if(name != llGetScriptName()) 47 | { 48 | string extension = llGetSubString(name, -4, -1); 49 | if(extension == ".lsl") modules += [name]; 50 | } 51 | } 52 | return modules; 53 | } 54 | 55 | default 56 | { 57 | state_entry() 58 | { 59 | llListen(CHANNEL, "", "", ""); 60 | } 61 | 62 | listen(integer channel, string name, key id, string data) 63 | { 64 | // TODO Handle invalid data 65 | string command = llJsonGetValue(data, ["command"]); 66 | string module = llJsonGetValue(data, ["module"]); 67 | 68 | if(command == "request") 69 | { 70 | string response = llList2Json(JSON_OBJECT, ["available", FALSE]); 71 | if(module_is_available(module)) 72 | { 73 | regenerate_pin(); 74 | response = llList2Json(JSON_OBJECT, ["available", TRUE, "pin", pin]); 75 | } 76 | llRegionSayTo(id, channel, response); 77 | return; 78 | } 79 | else if(command == "send") 80 | { 81 | llRemoteLoadScriptPin(id, module, pin, TRUE, 0xDEADBEEF); 82 | return; 83 | } 84 | else if(command == "query") 85 | { 86 | string modules = llList2Json(JSON_ARRAY, get_modules()); 87 | string response = llList2Json(JSON_OBJECT, ["modules", modules]); 88 | llRegionSayTo(id, channel, response); 89 | return; 90 | } 91 | 92 | string response = llList2Json(JSON_OBJECT, ["error", "Invalid command."]); 93 | llRegionSayTo(id, channel, response); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | colorama 3 | tabulate 4 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | - [ ] Add user/password login 3 | - [ ] Wrap Shell class in argparse 4 | - [ ] Add keep-alive 5 | - [ ] Add non-interactive mode to allow use in other scripts 6 | - [ ] Add verbose mode to see all communication 7 | - [ ] Add command to refresh command list of endpoint 8 | - [ ] Add versioning to modules 9 | - [ ] Formalize link message responses 10 | - [ ] Use bash-like exit codes (0, 1, 2, 127, etc.) as response 11 | - [ ] Formalize returned JSON 12 | - [ ] Update readme 13 | - [x] Only allow one session in use at a time 14 | - [x] Add command history 15 | - [x] Make lsl API script discover modules 16 | - [x] Make lsl API script communicate with modules 17 | --------------------------------------------------------------------------------