Shroud Contacts
Register Shroud dark web chat contacts and rooms from another resource.
Shroud is the laptop's anonymous chat app on the Onion network. Other resources register contacts in it, such as a dealer with listings or a fixer handing out jobs, and rooms, such as one for a gang. Messages from contacts carry text, cards with fields, inputs and buttons, listings players buy from, a location they can set a waypoint to, or a page from your resource shown inside the conversation.
See Messaging for Shroud from the player's side, its config and its traces. For Natter businesses, see Register Messenger Business.
These exports only exist while chat.enabled is true in the config. With it off, Shroud is gone and calling them fails, so a resource that works without Shroud wraps its calls in pcall.
General Information
Every export on this page is a server export. Contacts and rooms belong to the resource that registered them and are removed when it stops. The laptop forgets them when it restarts, so register them on the server event
zyke_laptop:Readyas well as when your resource starts.Exports that read or write messages wait on the database. Call them from a thread, an event handler or a command, not while your script loads.
Types / Classes
ChatContactRegistration,ChatMessageInput,ChatCard,ChatListing,ChatLocation,ChatFrameandChatRoomRegistrationare intypes/chat.luainsidezyke_laptop.
#Handles and Ids
A handle is 3 to 20 characters of a-z, 0-9, _, . and -. Upper case is turned to lower case.
A contact's handle is reserved, so no player can create it. A contact can't take a handle a player already holds: the registration fails, and a contact registered while the players' handles were still loading is removed with a warning in the server console once they have loaded.
Contact, room, listing, input and action ids are up to 64 letters, digits, _, . and -.
#Contacts
#Register Chat Contact
Adds a contact to Shroud. Registering the same
idagain replaces it. Returnsfalsewith a warning in the server console whenidorhandleis invalid or the handle is taken.
handlein the handlers is the player's handle, andplyIdthe player behind it. The handlers run on your server, so check and take money and hand out items there. An error in a handler is printed in the server console.
onMessageruns when the player writes to the contact.onActionruns when the player presses a button on one of the contact's cards.valuesholds what the player typed into the card's inputs, by input id.onPurchaseruns when the player buys from a listing. Returntrueto accept, orfalseand a message the app shows the player.Example:
lua---@param contact ChatContactRegistration ---@return boolean registered exports["zyke_laptop"]:RegisterChatContact({ id = "greenthumb", handle = "greenthumb", name = "Greenthumb", avatar = "web/greenthumb.webp", -- A path inside your resource, or a full URL description = "Fresh produce, discreet drops", category = "Dealers", listed = true, greeting = {text = "You found me. Pick what you need.", listings = true}, listings = {}, onMessage = function(plyId, handle, text, conversationId) end, onAction = function(plyId, handle, actionId, values, messageId) end, onPurchase = function(plyId, handle, listingId, quantity) return false, "Closed" end, })
| Field | Default | Description |
|---|---|---|
id | Required | Unique contact id, used by the other exports. |
handle | Required | The contact's handle in Shroud. |
name | The handle | Shown instead of the handle, up to 64 characters. |
avatar | None | An image path inside your resource, or a full URL. |
description | None | Shown in the directory, up to 500 characters. |
category | None | The directory section it is listed under, such as "Dealers". Up to 64 characters. |
listed | true | false keeps the contact out of the directory. Players reach it by typing its handle. |
greeting | None | A message sent the first time a player opens the conversation. |
listings | None | The contact's listings, shown by messages with listings = true. |
onMessage | None | function(plyId, handle, text, conversationId) |
onAction | None | function(plyId, handle, actionId, values, messageId) |
onPurchase | None | function(plyId, handle, listingId, quantity), returns boolean, string?. Listings can't be bought without it. |
#Remove Chat Contact
Removes a contact before your resource stops. Its conversations stay readable.
Example:
lua---@param contactId string ---@return boolean removed local removed = exports["zyke_laptop"]:RemoveChatContact("greenthumb")
#Set Chat Listings
Replaces the contact's listings. Listing messages players have on screen show the new ones at once. Returns
falsefor an unknown contact.Example:
lua---@param contactId string ---@param listings ChatListing[] ---@return boolean updated exports["zyke_laptop"]:SetChatListings("greenthumb", { {id = "lockpick", label = "Lockpick", price = 600, currency = "$", stock = 5, maxQuantity = 2}, })
#Get Chat Handle
Returns the handle the player is signed in with in Shroud, or
nil.Example:
lua---@param plyId integer ---@return string? handle local handle = exports["zyke_laptop"]:GetChatHandle(plyId)
#Messages
#Send Chat Message
Sends a message from the contact to a player's server id or a handle. Returns the message id, or
nilwhen the contact, target or message is invalid, no player holds the handle, or the player blocked the contact.It reaches players who are offline or signed out too. A server id reaches the handle the player last used with that contact, or the handle they are signed in with when they never talked to it. A contact never links two of a player's handles.
Example:
lua---@param contactId string ---@param target integer | string @ Player server id or handle ---@param message string | ChatMessageInput ---@return string? messageId local messageId = exports["zyke_laptop"]:SendChatMessage("greenthumb", plyId, { text = "Paid. It's in the bin behind the laundromat.", location = {x = 1136.4, y = -982.1, z = 46.4, label = "Laundromat, El Rancho Blvd"}, })
#Update Chat Message
Changes a message one of your resource's contacts sent.
text,listings,locationandframeare replaced. The keys incardare merged into the card, each replacing that key whole, soactionsreplaces every button.falseremoves a field. Returnsfalsefor a message your resource's contacts didn't send.Example:
lua---@param messageId string ---@param patch ChatMessageInput ---@return boolean updated exports["zyke_laptop"]:UpdateChatMessage(messageId, { card = {actions = {{id = "accept_job", label = "Accepted", disabled = true}}}, })
#Open Chat
Opens the contact's conversation in the player's Shroud if their screen is open. Otherwise the player gets a notification that the contact is waiting. A conversation opened this way for the first time gets the contact's greeting. Returns
falsefor an unknown contact or player.Example:
lua---@param plyId integer ---@param contactId string ---@return boolean sent exports["zyke_laptop"]:OpenChat(plyId, "greenthumb")
#Message Format
A message is a string, which is the same as {text = string}, or a table with any of these fields. Fields that aren't valid are dropped, and a message with nothing valid is not sent.
| Field | Description |
|---|---|
text | Text, up to 2000 characters. |
card | {title?, text?, image?, fields?, inputs?, actions?}. See Cards. |
listings | true shows the contact's listings, kept up to date by SetChatListings, or a fixed list of listings. |
location | {x, y, z?, label?} or a vector3. Players can set a waypoint to it. |
frame | {url, height?}: a page from your resource shown in the conversation, like a site in the browser. url is a path inside your resource or a full URL. height is in pixels, 320 when left out, shown between 120 and 640. |
A frame page can use the JavaScript SDK. It runs as the app "chat" and gets context.chat.messageId. It can't close or rename the Shroud window, and saveState rejects there. List the page under files in your fxmanifest.lua.
#Cards
| Field | Description |
|---|---|
title | Up to 120 characters. |
text | Up to 2000 characters. |
image | An image path inside your resource, or a full URL. |
fields | Rows of {label, value}. |
inputs | {id, label, type?, placeholder?, required?}. type is "text" or "number". |
actions | Buttons: {id, label, style?, disabled?}. style is "default", "suggested" or "destructive". |
A button press reaches onAction with the inputs' values by id. The laptop checks the inputs first: required ones must be filled and number ones must be numbers. A disabled button never reaches onAction. Labels are up to 120 characters, and each list holds up to 50 rows.
#Listings
| Field | Description |
|---|---|
id | Listing id, passed to onPurchase. |
label | Up to 120 characters. |
description | Optional. |
price | 0 or more. |
currency | Optional, such as "$". Up to 8 characters. |
stock | Optional. Units left; a purchase for more is refused before onPurchase runs. |
maxQuantity | Optional. Units one purchase may be for, 100 when left out. |
image | Optional. An image path inside your resource, or a full URL. |
The laptop doesn't count stock down. Update it with SetChatListings after each sale, as the example below does.
#A Dealer
A dealer who sells from listings, replies to a purchase with where to pick it up, takes drop-off requests through a form, and offers a job. The handlers check everything on the server: the player pays before anything is handed out, and the job starts only for the player it was offered to.
lualocal stock = {weed_bag = 40, lockpick = 5}
local prices = {weed_bag = 250, lockpick = 600}
local labels = {weed_bag = "Bag of weed", lockpick = "Lockpick"}
-- Job offers that are still open, by message id, so only the player it went to can accept it
local offers = {}
local function getListings()
return {
{id = "weed_bag", label = labels.weed_bag, price = prices.weed_bag, currency = "$", stock = stock.weed_bag, maxQuantity = 10},
{id = "lockpick", label = labels.lockpick, price = prices.lockpick, currency = "$", stock = stock.lockpick, maxQuantity = 2},
}
end
local function onPurchase(plyId, handle, listingId, quantity)
local price = prices[listingId] * quantity
if (stock[listingId] < quantity) then return false, "Sold out" end
if ((Z.money.get(plyId, "cash") or 0) < price) then return false, "Not enough cash" end
Z.money.remove(plyId, "cash", price)
Z.addItem(plyId, listingId, quantity)
stock[listingId] -= quantity
exports["zyke_laptop"]:SetChatListings("greenthumb", getListings())
exports["zyke_laptop"]:SendChatMessage("greenthumb", handle, {
text = "Paid. It's in the bin behind the laundromat.",
location = {x = 1136.4, y = -982.1, z = 46.4, label = "Laundromat, El Rancho Blvd"},
})
exports["zyke_laptop"]:SendChatMessage("greenthumb", handle, {
card = {
title = "Rather have it brought?",
text = "Tell me where and when. Drops cost $100 extra.",
inputs = {
{id = "place", label = "Where", placeholder = "Mirror Park, the lake", required = true},
{id = "time", label = "When", placeholder = "Tonight at 11"},
},
actions = {{id = "request_drop", label = "Send request", style = "suggested"}},
},
})
return true
end
local function onAction(plyId, handle, actionId, values, messageId)
if (actionId == "request_drop") then
-- Hand values.place and values.time to your delivery job, then lock the form
exports["zyke_laptop"]:UpdateChatMessage(messageId, {card = {actions = {{id = "request_drop", label = "Request sent", disabled = true}}}})
return
end
if (actionId == "accept_job" and offers[messageId] == plyId) then
offers[messageId] = nil
exports["zyke_laptop"]:UpdateChatMessage(messageId, {card = {actions = {{id = "accept_job", label = "Accepted", disabled = true}}}})
TriggerEvent("my_jobs:StartCourier", plyId)
end
end
local function registerDealer()
exports["zyke_laptop"]:RegisterChatContact({
id = "greenthumb",
handle = "greenthumb",
name = "Greenthumb",
category = "Dealers",
greeting = {text = "You found me. Pick what you need.", listings = true},
listings = getListings(),
onPurchase = onPurchase,
onAction = onAction,
onMessage = function(plyId, handle, text)
exports["zyke_laptop"]:SendChatMessage("greenthumb", handle, "Don't talk, buy.")
end,
})
end
AddEventHandler("zyke_laptop:Ready", registerDealer)
if (GetResourceState("zyke_laptop") == "started") then registerDealer() end
-- A job offer: a briefing page from your resource and a button that starts the job
RegisterCommand("offer_courier", function(plyId)
local messageId = exports["zyke_laptop"]:SendChatMessage("greenthumb", plyId, {
text = "Got a run for you. Read this first.",
frame = {url = "web/briefing.html", height = 320},
card = {
title = "Courier run",
fields = {{label = "Pay", value = "$4,000"}, {label = "Time limit", value = "20 minutes"}},
actions = {{id = "accept_job", label = "Accept", style = "suggested"}},
},
})
if (messageId) then offers[messageId] = plyId end
exports["zyke_laptop"]:OpenChat(plyId, "greenthumb")
end, true)
The briefing page is an ordinary page in your resource, listed under files in your fxmanifest.lua. A button on the page can also post to your resource with Laptop.post. Check on the server that the player was offered the job before starting it, as onAction does above.
#Rooms
#Register Chat Room
Adds a room players can join, for example one for a gang or a job. Registering the same
idagain replaces it.listed = falsekeeps it out of the directory, so only players who know its id can join. Returnsfalsefor an invalididorname.Example:
lua---@param room ChatRoomRegistration ---@return boolean registered exports["zyke_laptop"]:RegisterChatRoom({ id = "grove_street", name = "Grove Street", -- Up to 64 characters description = "Members only", -- Optional, up to 500 characters listed = false, })
#Remove Chat Room
Removes a room. Its conversation stays readable, but nobody can write there until the room is registered again.
Example:
lua---@param roomId string ---@return boolean removed local removed = exports["zyke_laptop"]:RemoveChatRoom("grove_street")
The public rooms every player can join are set in chat.rooms in the config.