scoped tokens support and readme update

This commit is contained in:
Madpeterz 2021-02-07 21:53:01 +00:00
parent b6ada34ed5
commit 9706a94f10
11 changed files with 331 additions and 44 deletions

View file

@ -57,6 +57,16 @@ namespace BetterSecondBotShared.IO
{
serializer.Serialize(writer, commandsfile);
}
}
public void WriteJsonTokens(JsonScopedTokens tokensfile, string targetfile)
{
JsonSerializer serializer = new JsonSerializer();
using (StreamWriter sw = new StreamWriter(@"" + root_folder + "" + targetfile))
using (JsonWriter writer = new JsonTextWriter(sw) { Formatting = Formatting.Indented })
{
serializer.Serialize(writer, tokensfile);
}
}
public void makeOld(string targetfile)

View file

@ -3,11 +3,19 @@ using System.Collections.Generic;
using System.Text;
namespace BetterSecondBotShared.Json
{
{
public class JsonScopedTokens
{
public string[] ScopedTokens { get; set; }
}
public class JsonCommandsfile
{
public string[] CustomCommands { get; set; }
}
public class JsonConfig
{
// Basics

View file

@ -53,7 +53,7 @@ namespace BetterSecondBot
*/
new Thread(() =>
{
new HttpAsService(Bot, Config);
new HttpAsService(Bot, Config, as_docker);
}).Start();
}
if(use_self_keep_alive == true)

View file

@ -20,7 +20,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/localchathistory/{token}")]
public object localchatHistory(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "chat", "localchathistory", getClientIP()) == true)
{
return BasicReply(JsonConvert.SerializeObject(bot.getLocalChatHistory()));
}
@ -33,7 +33,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Post, "/localchatsay/{token}")]
public object localchatSay(string token, [FormField] string message)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "chat", "localchatsay", getClientIP()) == true)
{
bot.GetCommandsInterface.Call("say", message);
return BasicReply(JsonConvert.SerializeObject(bot.getLocalChatHistory()));

View file

@ -59,7 +59,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/friends/{token}")]
public object Friends(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "friends", getClientIP()) == true)
{
return BasicReply(bot.getJsonFriendlist());
}
@ -75,7 +75,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/walkto/{x}/{y}/{z}/{token}")]
public object WalkTo(string x, string y, string z, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "walkto", getClientIP()) == true)
{
bool status = bot.GetCommandsInterface.Call("AutoPilot", "<"+x+","+y+","+z+">");
if (status == true)
@ -94,7 +94,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/gesture/{gesture}/{token}")]
public object Gesture(string gesture, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "gesture", getClientIP()) == true)
{
bool status = bot.GetCommandsInterface.Call("PlayGesture", gesture);
if (status == true)
@ -116,7 +116,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/teleport/{region}/{x}/{y}/{z}/{token}")]
public object Teleport(string region,string x, string y, string z, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "teleport", getClientIP()) == true)
{
bool status = bot.GetCommandsInterface.Call("Teleport", ""+region+"/" + x + "/" + y + "/" + z + "");
if (status == true)
@ -135,7 +135,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/regiontile/{regionname}/{token}")]
public object Regiontile(string regionname, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "regiontile", getClientIP()) == true)
{
if (bot.GetClient.Grid.GetGridRegion(regionname, GridLayerType.Objects, out GridRegion region))
{
@ -155,7 +155,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/nearmewithdetails/{token}")]
public object NearmeWithDetails(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "nearmewithdetails", getClientIP()) == true)
{
if (bot.GetClient.Network.CurrentSim != null)
{
@ -188,7 +188,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/nearme/{token}")]
public object Nearme(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "nearme", getClientIP()) == true)
{
if (bot.GetClient.Network.CurrentSim != null)
{
@ -224,7 +224,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/logout/{token}")]
public object Logout(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "logout", getClientIP()) == true)
{
return tokens.Expire(token);
}
@ -238,7 +238,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/version/{token}")]
public object Version(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "version", getClientIP()) == true)
{
return BasicReply(bot.MyVersion);
}
@ -250,7 +250,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/name/{token}")]
public object Name(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "name", getClientIP()) == true)
{
return BasicReply(bot.GetClient.Self.FirstName + " " + bot.GetClient.Self.LastName);
}
@ -263,7 +263,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/regionname/{token}")]
public object Regionname(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "regionname", getClientIP()) == true)
{
if(bot.GetClient.Network.CurrentSim != null)
{
@ -280,7 +280,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/location/{token}")]
public object Location(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "location", getClientIP()) == true)
{
if (bot.GetClient.Network.CurrentSim != null)
{
@ -302,7 +302,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Post, "/command/{token}")]
public async Task<object> command(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "core", "command", getClientIP()) == true)
{
CommandLibCall data = await HttpContext.GetRequestDataAsync<CommandLibCall>();
string compressedArgs = string.Join("~#~", data.Args);

View file

@ -26,7 +26,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/ismember/{group}/{avatar}/{token}")]
public object ismember(string group,string avatar,string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "groups", "ismember", getClientIP()) == true)
{
if (UUID.TryParse(group, out UUID groupuuid) == true)
{
@ -62,7 +62,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/listgroups/{token}")]
public object listgroups(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "groups", "listgroups", getClientIP()) == true)
{
Dictionary<string, string> grouppackage = new Dictionary<string, string>();
foreach(KeyValuePair<UUID,Group> entry in bot.MyGroups)
@ -83,7 +83,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/listgrouproles/{group}/{token}")]
public object listgrouproles(string group, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "groups", "listgrouproles", getClientIP()) == true)
{
if (UUID.TryParse(group, out UUID groupuuid) == true)
{
@ -132,7 +132,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/listgroupswithunread/{token}")]
public object listgroupswithunread(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "groups", "listgroupswithunread", getClientIP()) == true)
{
return BasicReply(JsonConvert.SerializeObject(bot.UnreadGroupchatGroups()));
}
@ -144,7 +144,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/haveunreadgroupchat/{token}")]
public object haveunreadgroupchat(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "groups", "haveunreadgroupchat", getClientIP()) == true)
{
return BasicReply(bot.HasUnreadGroupchats().ToString());
}
@ -158,7 +158,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/getgroupchat/{group}/{token}")]
public object getgroupchat(string group, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "groups", "getgroupchat", getClientIP()) == true)
{
if (UUID.TryParse(group, out UUID groupUUID) == true)
{
@ -177,7 +177,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Post, "/sendgroupchat/{group}/{token}")]
public object sendgroupchat(string group, string token, [FormField] string message)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "groups", "sendgroupchat", getClientIP()) == true)
{
if (UUID.TryParse(group, out UUID groupUUID) == true)
{

View file

@ -11,6 +11,9 @@ using EmbedIO.Security;
using BetterSecondBotShared.Static;
using System.Net;
using EmbedIO.Routing;
using BetterSecondBotShared.logs;
using BetterSecondBotShared.IO;
using Newtonsoft.Json;
namespace BetterSecondBot.HttpService
{
@ -19,11 +22,122 @@ namespace BetterSecondBot.HttpService
protected JsonConfig Config;
protected SecondBot Bot;
protected TokenStorage Tokens;
public HttpAsService(SecondBot MainBot, JsonConfig BotConfig)
protected void loadScopedTokensFromENV()
{
int loop = 1;
bool found = true;
while (found == true)
{
if (helpers.notempty(Environment.GetEnvironmentVariable("scoped_token_" + loop.ToString())) == true)
{
processScopedTokenRaw(Environment.GetEnvironmentVariable("scoped_token_" + loop.ToString()));
}
else
{
found = false;
}
loop++;
}
}
protected void loadScopedTokensFromFile()
{
JsonScopedTokens LoadedTokens = new JsonScopedTokens
{
ScopedTokens = new string[] { "t:[XXX],ws:core" }
};
string targetfile = "scoped_tokens.json";
SimpleIO io = new SimpleIO();
if (SimpleIO.FileType(targetfile, "json") == false)
{
io.WriteJsonTokens(LoadedTokens, targetfile);
return;
}
if (io.Exists(targetfile) == false)
{
io.WriteJsonTokens(LoadedTokens, targetfile);
return;
}
string json = io.ReadFile(targetfile);
if (json.Length > 0)
{
try
{
LoadedTokens = JsonConvert.DeserializeObject<JsonScopedTokens>(json);
foreach (string loaded in LoadedTokens.ScopedTokens)
{
processScopedTokenRaw(loaded);
}
}
catch
{
io.makeOld(targetfile);
io.WriteJsonTokens(LoadedTokens, targetfile);
}
return;
}
}
protected void processScopedTokenRaw(string raw)
{
raw = raw.Replace(" ", "");
string[] bits = raw.Split(',');
string code = null;
int accessids = 0;
scopedTokenInfo stinfo = new scopedTokenInfo();
foreach(string bit in bits)
{
string[] subbits = bit.Split(':');
if(subbits[0] == "t")
{
code = subbits[1];
}
else if (subbits[0] == "cm")
{
stinfo.AllowCommands.Add(subbits[1]);
accessids++;
}
else if (subbits[0] == "cg")
{
stinfo.AllowAccessGroups.Add(subbits[1]);
accessids++;
}
else if (subbits[0] == "ws")
{
stinfo.AllowWorkgroups.Add(subbits[1]);
accessids++;
}
}
if((code != null) && (accessids > 0))
{
Tokens.AddScopedToken(code, stinfo);
LogFormater.Info("Adding scopped command " + code + " with: " + accessids.ToString() + " access types");
}
else
{
LogFormater.Warn("Scoped access code: "+raw+" did not pass checks");
}
}
public HttpAsService(SecondBot MainBot, JsonConfig BotConfig, bool running_in_docker)
{
Bot = MainBot;
Config = BotConfig;
Tokens = new TokenStorage();
// Load scoped tokens
if(running_in_docker == true)
{
// using ENV values
loadScopedTokensFromENV();
}
else
{
// from file
loadScopedTokensFromFile();
}
// Our web server is disposable
Logger.UnregisterLogger<ConsoleLogger>();
using (var server = CreateWebServer(Config.Http_Host))
@ -39,7 +153,7 @@ namespace BetterSecondBot.HttpService
private WebServer CreateWebServer(string url)
{
Tokens = new TokenStorage();
var server = new WebServer(o => o
.WithUrlPrefix(url)
.WithMode(HttpListenerMode.EmbedIO))
@ -123,8 +237,18 @@ namespace BetterSecondBot.HttpService
public class TokenStorage
{
// global access tokens
protected Dictionary<string, tokenInfo> tokens = new Dictionary<string, tokenInfo>();
// scoped access tokens
protected Dictionary<string, scopedTokenInfo> scopedtokens = new Dictionary<string, scopedTokenInfo>();
public void AddScopedToken(string code, scopedTokenInfo info)
{
scopedtokens.Add(code, info);
}
public void PurgeExpired()
{
List<string> oldTokens = new List<string>();
@ -154,7 +278,7 @@ namespace BetterSecondBot.HttpService
return "Failed to remove token";
}
public bool Allow(string code, IPAddress IPaddress)
public bool Allow(string code, string workgroup, string command, IPAddress IPaddress)
{
if(tokens.ContainsKey(code) == true)
{
@ -169,7 +293,35 @@ namespace BetterSecondBot.HttpService
return true;
}
}
return false;
return ScopeAllow(code, workgroup, command);
}
public bool ScopeAllow(string code, string workgroup, string command)
{
if (scopedtokens.ContainsKey(code) == false)
{
return false;
}
scopedTokenInfo info = scopedtokens[code];
string build = workgroup + "/" + command;
if (info.AllowWorkgroups.Contains(workgroup) == true)
{
return true;
}
else if(info.AllowCommands.Contains(build) == true)
{
return true;
}
bool allowed = false;
foreach(string A in info.AllowAccessGroups)
{
allowed = accessgroupcontrol.isAllowed(A, workgroup, command);
if(allowed == true)
{
break;
}
}
return allowed;
}
public string CreateToken(IPAddress IP)
@ -237,4 +389,51 @@ namespace BetterSecondBot.HttpService
public IPAddress IP = null;
}
public class scopedTokenInfo
{
public List<string> AllowWorkgroups = new List<string>();
public List<string> AllowAccessGroups = new List<string>();
public List<string> AllowCommands = new List<string>();
}
public static class accessgroupcontrol
{
public static bool isAllowed(string permsgroupname, string workgroup, string command)
{
string build = workgroup + "/" + command;
List<string> allowed = new List<string>();
if (permsgroupname == "chat")
{
allowed = new List<string>(){
"chat/localchathistory",
"chat/localchatsay",
"groups/getgroupchat",
"groups/sendgroupchat",
"groups/listgroups",
"im/chatwindows",
"im/listwithunread",
"im/getimchat",
"im/sendimchat"
};
}
else if (permsgroupname == "giver")
{
allowed = new List<string>(){
"inventory/send",
"inventory/folders",
"inventory/contents"
};
}
else if (permsgroupname == "movement")
{
allowed = new List<string>(){
"core/walkto",
"core/teleport",
"core/gesture"
};
}
return allowed.Contains(build);
}
}
}

View file

@ -21,7 +21,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/chatwindows/{token}")]
public object chatwindows(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "im", "chatwindows", getClientIP()) == true)
{
return BasicReply(JsonConvert.SerializeObject(bot.GetIMChatWindowKeyNames()));
}
@ -33,7 +33,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/listwithunread/{token}")]
public object listwithunread(string group, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "im", "listwithunread", getClientIP()) == true)
{
List<UUID> unreadimwindows = new List<UUID>();
foreach (UUID window in bot.IMChatWindows())
@ -53,7 +53,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/haveunreadims/{token}")]
public object haveunreadims(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "im", "haveunreadims", getClientIP()) == true)
{
bool reply = false;
foreach(UUID window in bot.IMChatWindows())
@ -76,7 +76,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/getimchat/{window}/{token}")]
public object getimchat(string window, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "im", "getimchat", getClientIP()) == true)
{
if (UUID.TryParse(window, out UUID windowUUID) == true)
{
@ -94,7 +94,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Post, "/sendimchat/{avatar}/{token}")]
public object sendimchat(string avatar, string token, [FormField] string message)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "im", "sendimchat", getClientIP()) == true)
{
bot.GetCommandsInterface.Call("im", avatar + "~#~" + message, UUID.Zero, "~#~");
return BasicReply("ok");

View file

@ -20,7 +20,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/rezobject/{item}/{token}")]
public object rezobject(string item, string token, [FormField] string newname)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "inventory", "rezobject", getClientIP()) == true)
{
bool status = bot.GetCommandsInterface.Call("RezObject", item);
return BasicReply(status.ToString());
@ -35,7 +35,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Post, "/rename/{item}/{token}")]
public object rename(string item, string token, [FormField] string newname)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "inventory", "rename", getClientIP()) == true)
{
bool status = bot.GetCommandsInterface.Call("RenameInventory", "12~#~" + item + "~#~" + newname);
return BasicReply(status.ToString());
@ -50,7 +50,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/realuuid/{item}/{token}")]
public object realuuid(string item, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "inventory", "realuuid", getClientIP()) == true)
{
if (UUID.TryParse(item, out UUID itemUUID) == true)
{
@ -73,7 +73,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/send/{item}/{avatar}/{token}")]
public object send(string item, string avatar, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "inventory", "send", getClientIP()) == true)
{
if (UUID.TryParse(item, out UUID itemUUID) == true)
{
@ -94,7 +94,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/delete/{item}/{isfolder}/{token}")]
public object delete(string item,string isfolder, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "inventory", "delete", getClientIP()) == true)
{
if (UUID.TryParse(item, out UUID itemUUID) == true)
{
@ -118,7 +118,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/folders/{token}")]
public object folders(string token)
{
if(tokens.Allow(token, getClientIP()) == true)
if(tokens.Allow(token, "inventory", "folders", getClientIP()) == true)
{
string reply = HelperInventory.MapFolderJson(bot);
if(reply != null) return BasicReply(reply);
@ -134,7 +134,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/contents/{folderUUID}/{token}")]
public object Contents(string folderUUID, string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "inventory", "contents", getClientIP()) == true)
{
if (UUID.TryParse(folderUUID, out UUID folder) == true)
{

View file

@ -23,7 +23,7 @@ namespace BetterSecondBot.HttpService
[Route(HttpVerbs.Get, "/parcelbanlist/{token}")]
public object parcelbanlist(string token)
{
if (tokens.Allow(token, getClientIP()) == true)
if (tokens.Allow(token, "parcelestate", "parcelbanlist", getClientIP()) == true)
{
if (bot.GetClient.Network.CurrentSim != null)
{

View file

@ -25,9 +25,7 @@ Secondbot is a CommandLine based bot for SecondLife based on libremetaverse reta
## Releases
These are pre compiled versions for the most upto date please use docker or compile it yourself.
[Windows x64](https://github.com/Madpeterz/SecondBot/releases/tag/windows-x64)
[Linux (Tested on Deb) x64](https://github.com/Madpeterz/SecondBot/releases/tag/linux-x64)
[View releases](https://github.com/Madpeterz/SecondBot/releases)
### Starting multiple bots from the same executable in windows
@ -66,5 +64,77 @@ and the command would look something like this
sayexample!!!say|||Hello [C_ARG_1]{-}delay|||2500{-}say|||Bye [C_ARG_2]
### HTTP web interface
by setting
> Http_Enable to true
> Http_Host to http://*:8080
> Security_WebUIKey to a vaild code (12 letters+numbers long or more)
you will be able to connect to the bot via HTTP and use the webUI
to control the bot!
[Dev server UI](http://webui.magicmadpeter.xyz/)
or
[Host it yourself](https://github.com/Madpeterz/secondbot_web_folders)
### HTTP scoped tokens
---
Scoped tokens allow you to hardcode access to the HTTP interface (if enabled)
and set more detailed control over what areas can be accessed
plus no need to give the full http
> Security_WebUIKey
You get set these tokens up by file or Environment Variables.
##### File
> {
> "ScopedTokens": [
> "t:[10charcode],ws:core,ws:group",
> "t:[10charcode],cm:chat/localchathistory",
> ]
> }
##### Environment Variables
|Name | Value|
|--|--|
| scoped_token_1 | "t:[10charcode],ws:core,ws:group" |
| scoped_token_2 | "t:[10charcode],cg:chat" |
| scoped_token_3 | "t:[10charcode],cm:chat/localchathistory" |
### HTTP scoped tokens (info)
---
command [cm] "example: `cm:chat/localchathistory`
workspaces [ws] "example: ws: core, ws: groups"
commandgroups [cg] "example: `cg:chat`"
chat
chat/localchathistory
chat/localchatsay
groups/getgroupchat
groups/sendgroupchat
groups/listgroups
im/chatwindows
im/listwithunread
im/getimchat
im/sendimchat
giver
inventory/send
inventory/folders
inventory/contents
groupinvite
[WIP]
movement
core/walkto
core/teleport
core/gesture