diff --git a/pymetaverse/bot.py b/pymetaverse/bot.py new file mode 100644 index 0000000..565c63a --- /dev/null +++ b/pymetaverse/bot.py @@ -0,0 +1,131 @@ +import asyncio +import struct +import math +import random +from .eventtarget import EventTarget +from . import login +from . import viewer as Viewer +from .const import * + +class SimpleBot(EventTarget): + def __init__(self): + super().__init__() + self.agent = Viewer.Agent() + self.agent.on("message", self.handleMessage) + + async def handleSystemMessages(self, simulator, message): + # We only really care about the parent simulator here + if simulator == self.simulator: + pass + + if message.name == "RegionHandshake": + # Send some stuff to make the simulator happy about our presence + msg = self.messageTemplate.getMessage("AgentThrottle") + msg.AgentData.AgentID = self.agent.agentId + msg.AgentData.SessionID = self.agent.sessionId + msg.AgentData.CircuitCode = self.agent.circuitCode + msg.Throttle.GenCounter = 0 + msg.Throttle.Throttles = struct.pack("<7f", + #http://wiki.secondlife.com/wiki/AgentThrottle + 150000, #Resend + 170000, #Land + 34000, #Wind + 34000, #Cloud + 446000, #Task + 446000, #Texture + 220000 #Asset + ) + self.send(msg) + + msg = self.messageTemplate.getMessage("AgentFOV") + msg.AgentData.AgentID = self.agent.agentId + msg.AgentData.SessionID = self.agent.sessionId + msg.AgentData.CircuitCode = self.agent.circuitCode + msg.FOVBlock.GenCounter = 0 + msg.FOVBlock.VerticalAngle = 6.233185307179586 + self.send(msg) + + msg = self.messageTemplate.getMessage("AgentHeightWidth") + msg.AgentData.AgentID = self.agent.agentId + msg.AgentData.SessionID = self.agent.sessionId + msg.AgentData.CircuitCode = self.agent.circuitCode + msg.HeightWidthBlock.GenCounter = 0 + msg.HeightWidthBlock.Height = 0xffff + msg.HeightWidthBlock.Width = 0xffff + self.send(msg) + + + @property + def simulator(self): + return self.agent.simulator + + @property + def messageTemplate(self): + return self.agent.messageTemplate + + async def handleMessage(self, simulator, message): + self.agentUpdate() + await self.handleSystemMessages(simulator, message) + await self.fire("message", simulator, message, name=message.name) + + def send(self, message, reliable = False): + self.agent.send(message, reliable) + + + async def login(self, username, password): + loginHandle = await login.Login(username, password, isBot = True) + if loginHandle["login"] == False: + raise ValueError("Incorrect username or password") + + await self.agent.login(loginHandle) + + async def run(self): + await self.agent.run() + + def logout(self): + self.agent.logout() + + def say(self, channel, message, ctype = CHAT_TYPE_NORMAL): + if channel >= 0: + msg = self.messageTemplate.getMessage("ChatFromViewer") + msg.AgentData.AgentID = self.agent.agentId + msg.AgentData.SessionID = self.agent.sessionId + msg.ChatData.Message = message.encode() + b"\0" + msg.ChatData.Type = ctype + msg.ChatData.Channel = channel + self.send(msg) + + else: + # Because ChatFromViewer.Message.Channel is a signed int, we have + # to use dialogs here + msg = self.messageTemplate.getMessage("ScriptDialogReply") + msg.AgentData.AgentID = self.agent.agentId + msg.AgentData.SessionID = self.agent.sessionId + msg.Data.ObjectID = self.agent.agentId + msg.Data.ChatChannel = channel + msg.Data.ButtonIndex = 0 + msg.Data.ButtonLabel = message.encode() + b"\0" + self.send(msg) + + + def agentUpdate(self, controls = 0, forward = 0, state = 0, flags = 0): + angle_rad = math.radians(forward) + half_angle = angle_rad / 2 + + sin_half = math.sin(half_angle) + cos_half = math.cos(half_angle) + + msg = self.messageTemplate.getMessage("AgentUpdate") + msg.AgentData.AgentID = self.agent.agentId + msg.AgentData.SessionID = self.agent.sessionId + msg.AgentData.BodyRotation = (0.0, 0.0, sin_half, cos_half) + msg.AgentData.HeadRotation = (0.0, 0.0, sin_half, cos_half) + msg.AgentData.State = state + msg.AgentData.CameraCenter = (0, 0, 0) + msg.AgentData.CameraAtAxis = (0, 0.999999, 0) + msg.AgentData.CameraLeftAxis = (0.999999, 0, 0) + msg.AgentData.CameraUpAxis = (0, 0, 0.999999) + msg.AgentData.Far = math.inf + msg.AgentData.ControlFlags = controls + msg.AgentData.Flags = flags + self.send(msg) \ No newline at end of file diff --git a/pymetaverse/const.py b/pymetaverse/const.py new file mode 100644 index 0000000..463053d --- /dev/null +++ b/pymetaverse/const.py @@ -0,0 +1,54 @@ +# llui/llchat.h +CHAT_TYPE_WHISPER = 0 +CHAT_TYPE_NORMAL = 1 +CHAT_TYPE_SHOUT = 2 +CHAT_TYPE_START = 4 +CHAT_TYPE_STOP = 5 +CHAT_TYPE_DEBUG_MSG = 6 +CHAT_TYPE_REGION = 7 +CHAT_TYPE_OWNER = 8 +CHAT_TYPE_DIRECT = 9 + +# newview/llviewermessage.cpp +AU_FLAGS_NONE = 0x00 +AU_FLAGS_HIDETITLE = 0x01 +AU_FLAGS_CLIENT_AUTOPILOT = 0x02 + +# llcommon/indra_constants.h +AGENT_CONTROL_AT_POS = 0x00000001 +AGENT_CONTROL_AT_NEG = 0x00000002 +AGENT_CONTROL_LEFT_POS = 0x00000004 +AGENT_CONTROL_LEFT_NEG = 0x00000008 +AGENT_CONTROL_UP_POS = 0x00000010 +AGENT_CONTROL_UP_NEG = 0x00000020 +AGENT_CONTROL_PITCH_POS = 0x00000040 +AGENT_CONTROL_PITCH_NEG = 0x00000080 +AGENT_CONTROL_YAW_POS = 0x00000100 +AGENT_CONTROL_YAW_NEG = 0x00000200 + +AGENT_CONTROL_FAST_AT = 0x00000400 +AGENT_CONTROL_FAST_LEFT = 0x00000800 +AGENT_CONTROL_FAST_UP = 0x00001000 + +AGENT_CONTROL_FLY = 0x00002000 +AGENT_CONTROL_STOP = 0x00004000 +AGENT_CONTROL_FINISH_ANIM = 0x00008000 +AGENT_CONTROL_STAND_UP = 0x00010000 +AGENT_CONTROL_SIT_ON_GROUND = 0x00020000 +AGENT_CONTROL_MOUSELOOK = 0x00040000 + +AGENT_CONTROL_NUDGE_AT_POS = 0x00080000 +AGENT_CONTROL_NUDGE_AT_NEG = 0x00100000 +AGENT_CONTROL_NUDGE_LEFT_POS = 0x00200000 +AGENT_CONTROL_NUDGE_LEFT_NEG = 0x00400000 +AGENT_CONTROL_NUDGE_UP_POS = 0x00800000 +AGENT_CONTROL_NUDGE_UP_NEG = 0x01000000 +AGENT_CONTROL_TURN_LEFT = 0x02000000 +AGENT_CONTROL_TURN_RIGHT = 0x04000000 + +AGENT_CONTROL_AWAY = 0x08000000 + +AGENT_CONTROL_LBUTTON_DOWN = 0x10000000 +AGENT_CONTROL_LBUTTON_UP = 0x20000000 +AGENT_CONTROL_ML_LBUTTON_DOWN = 0x40000000 +AGENT_CONTROL_ML_LBUTTON_UP = 0x80000000 \ No newline at end of file diff --git a/pymetaverse/eventtarget.py b/pymetaverse/eventtarget.py index 42efcd7..8ee02be 100644 --- a/pymetaverse/eventtarget.py +++ b/pymetaverse/eventtarget.py @@ -1,24 +1,37 @@ +import asyncio +import inspect + class EventTarget: def __init__(self): - self._listeners = {} - - def on(self, event, func = None): - if not event in self._listeners: + self._listeners = {} # event -> list of (func, filters) + + def on(self, event, func=None, **filters): + if event not in self._listeners: self._listeners[event] = [] - - def _(func): - self._listeners[event].append(func) - return func - - if func: - return _(func) - return _ - + + def decorator(f): + self._listeners[event].append((f, filters)) + return f + + return decorator(func) if func else decorator + def off(self, event, func): if event in self._listeners: - self._listeners[event].remove(func) + self._listeners[event] = [ + (f, filt) for (f, filt) in self._listeners[event] if f != func + ] + + async def fire(self, event, *args, **kwargs): + listeners = self._listeners.get(event, []) + for func, filters in listeners: + if all(kwargs.get(k) == v for k, v in filters.items()): + if inspect.iscoroutinefunction(func): + await func(*args) + else: + func(*args) - def fire(self, event, *args, **kwargs): - if event in self._listeners: - for func in self._listeners[event]: - func(*args, **kwargs) \ No newline at end of file + def fireSync(self, event, *args, **kwargs): + listeners = self._listeners.get(event, []) + for func, filters in listeners: + if all(kwargs.get(k) == v for k, v in filters.items()): + func(*args) diff --git a/pymetaverse/viewer/agent.py b/pymetaverse/viewer/agent.py index def3d08..a5b2372 100644 --- a/pymetaverse/viewer/agent.py +++ b/pymetaverse/viewer/agent.py @@ -30,13 +30,26 @@ class Agent(EventTarget): if self.simulator: self.simulator.send(msg, reliable) - def handleMessage(self, sim, msg): - self.fire("message", sim, msg) - - @classmethod - async def fromLogin(cls, login): - self = cls() + async def handleSystemMessages(self, sim, msg): + if msg.name == "DisableSimulator": + if sim == self.simulator: + self.simulator = None + + if sim in self.simulators: + self.simulators.remove(sim) + elif msg.name == "LogoutReply" or msg.name == "KickUser": + for simulator in self.simulators: + self.simulators.remove(simulator) + self.simulator = None + await self.fire("logout") + + + async def handleMessage(self, sim, msg): + await self.handleSystemMessages(sim, msg) + await self.fire("message", sim, msg) + + async def login(self, login): if login["login"] == False: raise ValueError("Invalid login handle") @@ -52,12 +65,21 @@ class Agent(EventTarget): msg.AgentData.SessionID = self.sessionId msg.AgentData.CircuitCode = self.circuitCode self.send(msg, True) - - return self + + def logout(self): + msg = self.messageTemplate.getMessage("LogoutRequest") + msg.AgentData.AgentID = self.agentId + msg.AgentData.SessionID = self.sessionId + self.send(msg, True) async def run(self): while True: - await asyncio.sleep(5) - if self.simulator == None: - break + try: + await asyncio.sleep(0.1) + if self.simulator == None: + break + + except asyncio.exceptions.CancelledError: + # Attempt to gracefully logout + self.logout() \ No newline at end of file diff --git a/pymetaverse/viewer/circuit.py b/pymetaverse/viewer/circuit.py index ada56b1..821eb2e 100644 --- a/pymetaverse/viewer/circuit.py +++ b/pymetaverse/viewer/circuit.py @@ -32,15 +32,30 @@ class Circuit(asyncio.Protocol, EventTarget): if pkt.flags & pkt.FLAGS.ACK: self.acknowledge(pkt.acks) - self.fire("message", addr, pkt.body) + asyncio.create_task(self.fire("message", addr, pkt.body)) def error_received(self, exc): - self.fire("error", exc) + asyncio.create_task(self.fire("error", exc)) def connection_lost(self, exc): - self.fire("close", exc) + if not self.transport: + return + + self.transport = None + asyncio.create_task(self.fire("close", exc)) + + def close(self): + if not self.transport: + return + + self.transport.close() + self.transport = None + asyncio.create_task(self.fire("close", None)) def send(self, message, reliable = False): + if not self.transport: + return + pkt = packet.Packet(self.nextSequence(), bytes(message), acks=self.acks) if reliable: pkt.reliable = True @@ -49,6 +64,9 @@ class Circuit(asyncio.Protocol, EventTarget): self.transport.sendto(pkt.toBytes()) def resend(self, distance = 100): + if not self.transport: + return + cutoff = self.sequence - distance for pkt in self.unackd.values(): if pkt.sequence < cutoff: diff --git a/pymetaverse/viewer/simulator.py b/pymetaverse/viewer/simulator.py index 0f117cd..f16b9d9 100644 --- a/pymetaverse/viewer/simulator.py +++ b/pymetaverse/viewer/simulator.py @@ -1,5 +1,6 @@ from .circuit import Circuit from . import messages +from . import region from .. import httpclient from .. import llsd from ..eventtarget import EventTarget @@ -32,7 +33,7 @@ class Simulator(EventTarget): msg.CircuitCode.ID = self.agent.agentId self.send(msg, True) - def handleSystemMessages(self, msg): + async def handleSystemMessages(self, msg): if msg.name == "PacketAck": acks = [] for ack in msg.Packets: @@ -53,20 +54,23 @@ class Simulator(EventTarget): msg = self.messageTemplate.getMessage("RegionHandshakeReply") msg.AgentData.AgentID = self.agent.agentId msg.AgentData.SessionID = self.agent.sessionId - msg.RegionInfo.Flags = 2 + msg.RegionInfo.Flags = 1 self.send(msg, True) + + elif msg.name == "DisableSimulator": + self.circuit.close() - def handleMessage(self, addr, body): + async def handleMessage(self, addr, body): # Reject unknown hosts as a security precaution if addr != self.host: return msg = self.messageTemplate.loadMessage(body) - self.handleSystemMessages(msg) + await self.handleSystemMessages(msg) # Don't break the whole script! try: - self.fire("message", self, msg) + await self.fire("message", self, msg, name=msg.name) except Exception as e: traceback.print_exc() diff --git a/readme.md b/readme.md index 1b231bf..5e852d0 100644 --- a/readme.md +++ b/readme.md @@ -1,20 +1,36 @@ # Second Life viewer in python ```py import asyncio -import json +import datetime from pymetaverse import login -from pymetaverse.agent import Agent +from pymetaverse.bot import SimpleBot +from pymetaverse.const import * + +bot = SimpleBot() + +@bot.on("message", name="ChatFromSimulator") +def ChatFromSimulator(simulator, message): + # Ignore start / stop + if message.ChatData.ChatType in (4, 5): + return + + sender = message.ChatData.FromName.rstrip(b"\0").decode() + text = message.ChatData.Message.rstrip(b"\0").decode() + + if text == "logout": + bot.say(0, "Ok!") + bot.logout() + + print("[{}] {}: {}".format( + datetime.datetime.now().strftime("%Y-%M-%d %H:%m:%S"), + sender, + text + )) async def main(): - # Set argument isBot = False if human - loginHandle = await login.Login(username=("firstname", "lastname"), password="A secret to everyone") - - agent = await Agent.fromLogin(loginHandle) - @agent.on("message") - def handleMessage(simulator, message): - print(simulator, message) - - await agent.run() + await bot.login(("Magellan", "Linden"), "CrystalPrims") + await bot.run() +# Run everything asyncio.run(main()) ``` \ No newline at end of file