Documentation

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:Ready as 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, ChatFrame and ChatRoomRegistration are in types/chat.lua inside zyke_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 id again replaces it. Returns false with a warning in the server console when id or handle is invalid or the handle is taken.

handle in the handlers is the player's handle, and plyId the 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.

  • onMessage runs when the player writes to the contact.
  • onAction runs when the player presses a button on one of the contact's cards. values holds what the player typed into the card's inputs, by input id.
  • onPurchase runs when the player buys from a listing. Return true to accept, or false and 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,
})
FieldDefaultDescription
idRequiredUnique contact id, used by the other exports.
handleRequiredThe contact's handle in Shroud.
nameThe handleShown instead of the handle, up to 64 characters.
avatarNoneAn image path inside your resource, or a full URL.
descriptionNoneShown in the directory, up to 500 characters.
categoryNoneThe directory section it is listed under, such as "Dealers". Up to 64 characters.
listedtruefalse keeps the contact out of the directory. Players reach it by typing its handle.
greetingNoneA message sent the first time a player opens the conversation.
listingsNoneThe contact's listings, shown by messages with listings = true.
onMessageNonefunction(plyId, handle, text, conversationId)
onActionNonefunction(plyId, handle, actionId, values, messageId)
onPurchaseNonefunction(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 false for 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 nil when 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, location and frame are replaced. The keys in card are merged into the card, each replacing that key whole, so actions replaces every button. false removes a field. Returns false for 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 false for 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.

FieldDescription
textText, up to 2000 characters.
card{title?, text?, image?, fields?, inputs?, actions?}. See Cards.
listingstrue 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

FieldDescription
titleUp to 120 characters.
textUp to 2000 characters.
imageAn image path inside your resource, or a full URL.
fieldsRows of {label, value}.
inputs{id, label, type?, placeholder?, required?}. type is "text" or "number".
actionsButtons: {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

FieldDescription
idListing id, passed to onPurchase.
labelUp to 120 characters.
descriptionOptional.
price0 or more.
currencyOptional, such as "$". Up to 8 characters.
stockOptional. Units left; a purchase for more is refused before onPurchase runs.
maxQuantityOptional. Units one purchase may be for, 100 when left out.
imageOptional. 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.

lua
local 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 id again replaces it. listed = false keeps it out of the directory, so only players who know its id can join. Returns false for an invalid id or name.

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.