Evidence Bridge

How seized trap phones reach your police paperwork — built-in Evidence View, hooks and events for logging reports, and writing an MDT bridge so Send report attaches a phone to a case in your own MDT.

How seized trap phones reach your police paperwork. This page is for server owners and developers who want an officer's Send report to land in their own MDT (the police computer where cases live), or who want phone evidence logged somewhere without an MDT at all.

Running oxide-police? You don't need this page: its bridge ships ready and switched on (see Installation → oxide-police).

TL;DR for other MDTs — Drop a Lua file in server/integrations/evidence/, register it with EvidenceBackend.Register under your MDT's resource name, implement AttachReport, add a row for it under Pressure → Evidence → Evidence bridges in /drugselling settings, and restart.

The Three Layers

LayerWhat it gives youWhat you need
1. Built inOfficers read a seized phone in game: texts, contacts, meets, number tracesNothing. Works on every server
2. HooksYour own code gets the whole phone as a report every time an officer opens oneA few lines in shared/config.lua, or a small resource of your own
3. MDT bridgesSend report attaches the phone to a case in your MDTA bridge file for your MDT

Each layer works without the ones below it. A server with no police resource at all still gets layer 1.

Layer 1: Built In

A trap phone is a normal inventory item, so whatever your police use to search people, confiscate items or store evidence already moves it. When an officer with an evidence job uses a phone that isn't theirs, they get Evidence View: a read-only copy of the phone with every conversation, the contacts, the booked meets (with GPS), and Trace number on other burners' numbers.

Nothing to install or configure. The settings are in Pressure → Evidence (see Configuration → Evidence).

Without a bridge, Send report tells the officer "No case system". Everything else works.

Layer 2: Hooks

Every time an officer opens a seized phone, oxide-drugselling builds a report of the whole phone, including a plain-text copy (report.text) you can paste anywhere. You can catch it three ways.

A config function

Config.OnPhoneExamined in shared/config.lua runs on the server with the officer's server ID and the report. It's never stored in the database, so edits apply after a restart.

shared/config.lua
Config.OnPhoneExamined = function(src, report)
    if not report then return end
    print(('[evidence] officer %d examined %s'):format(src, report.number))
end

shared/config.lua is also sent to every player's game. Don't put webhook URLs or passwords in it. For anything with a secret, use the event below from your own resource.

Server events

These fire on the server only. Players can't trigger them.

EventArgumentsWhen
oxide-drugselling:evidence:phoneExaminedsrc, reportAn officer opens a seized phone
oxide-drugselling:evidence:numberTracedsrc, number, foundAn officer traces a number
oxide-drugselling:evidence:reportSentsrc, caseRef, report, okAn officer presses Send report, whether or not a bridge accepted it

Example: post examined phones to a Discord channel

This is a tiny resource of your own, so the webhook stays on the server.

Make a folder resources/evidence_log/.

Create evidence_log/fxmanifest.lua:

evidence_log/fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
server_script 'server.lua'

Create evidence_log/server.lua:

evidence_log/server.lua
local WEBHOOK = GetConvar('evidence_webhook', '')

AddEventHandler('oxide-drugselling:evidence:phoneExamined', function(src, report)
    if WEBHOOK == '' or type(report) ~= 'table' then return end

    -- Discord messages are limited to 2000 characters
    local body = report.text or ''
    if #body > 1800 then body = body:sub(1, 1800) .. '\n...' end

    PerformHttpRequest(WEBHOOK, function() end, 'POST', json.encode({
        content = ('Seized phone %s examined by %s\n```\n%s\n```'):format(
            report.number or '?', GetPlayerName(src) or tostring(src), body),
    }), { ['Content-Type'] = 'application/json' })
end)

In server.cfg, add your webhook and start the resource with your other scripts:

server.cfg
set evidence_webhook "https://discord.com/api/webhooks/your/webhook"
ensure evidence_log

set keeps the value on the server; players can't read it.

Exports

Other server scripts can pull phone evidence whenever they like: BuildEvidenceReport, GetPhoneRecord, GetPhoneMessages, GetPhoneContacts, GetPhoneByNumber, TraceNumber and GetPhoneIdFromItem. See Exports → Phones and evidence.

Layer 3: MDT Bridges

A bridge is one small Lua file in server/integrations/evidence/. That folder isn't protected, so you can open, copy and add files there. Every .lua file in it is loaded when oxide-drugselling starts.

A bridge file registers itself with one call:

EvidenceBackend.Register('resource-name', {
    AttachReport = function(src, caseRef, report) ... end,   -- required
    CreateCase   = function(src, report) ... end,            -- optional
    FlagNumber   = function(number, info) ... end,           -- optional
})

How a bridge is picked

The list in Pressure → Evidence → Evidence bridges (switch the menu to Advanced) decides which bridge gets reports. It's saved in your database as Config.Integrations.evidence:

shared/config/pressure.lua
Config.Integrations = {
    evidence = {
        { name = 'oxide-police', enabled = true },
    },
}

Every time a bridge is needed, the rows are checked from top to bottom. The first row that:

  1. is switched on (enabled isn't false),
  2. has a bridge registered under exactly that name, and
  3. names a resource that's running right now

is used. Only one bridge is used at a time. So the name in the list, the name passed to EvidenceBackend.Register, and the MDT's resource folder name must all be the same.

Because it's checked each time, an MDT that starts or restarts after oxide-drugselling is picked up as soon as it's running.

The list is stored in your database. Editing shared/config/pressure.lua won't change it on a server that has already started once. Add your row in the settings menu.

The contract

Bridge functions are called with plain arguments (no self, no colon). Any error inside them is caught and logged, and the officer sees "Case system error".

AttachReport (required)

Called when an officer presses Send report and types a case number.

ArgumentTypeWhat it is
srcnumberThe officer's server ID
caseRefstringThe case number they typed, in capital letters, 1 to 40 characters
reporttableThe whole phone. See The report

Return: true when the report was attached. Otherwise false and a reason.

What the officer sees on the phone:

Your function...The phone shows
returns true"Report attached"
returns false, 'Case LSPD-0042 not found' (a sentence with spaces)Your sentence, as written
returns false and a single word"Failed". If the word is one of the phone's own error codes, that code's message instead: 'rejected' shows "Not sent"
returns false with no reason"Case system error"
throws an error"Case system error"

The phone screen is small, so keep reasons short.

CreateCase (optional)

CreateCase(src, report) is part of the contract for MDTs that can open a new case from a report. This version of oxide-drugselling never calls it, so leaving it out changes nothing.

FlagNumber (optional)

Called after an officer's trace finds a phone.

ArgumentTypeWhat it is
numberstringThe traced number, like '555-9876'
infotable{ traced = true, by = <the officer's character identifier> }

The return value is ignored. Use it to mark the number in your MDT, add it to a watch list, or log it.

The report

AttachReport, Config.OnPhoneExamined, the phoneExamined event and the BuildEvidenceReport export all get the same table:

FieldTypeWhat it is
phoneIdstringThe phone's hidden ID
numberstringIts current number
ownerstring|nilThe registered owner's name. Only filled when Names the registered owner (Config.Evidence.trace.showOwner) is on
contactstableA list of { alias, number, deals, persona }. NPC clients have alias, deals and persona; other burners have alias and number (both the number)
threadstableUp to 30 conversations, newest first, each { title, thread, messages }. messages is the last 40, oldest first, each { dir = 'in'|'out', text, time }
meetstableBooked meets this phone handles, each { alias, time = 'HH:MM', street, type = 'handoff'|'drop', coords = { x, y, z } }
textstringEverything above as plain text

In threads, thread is 'c:<clientId>' for an NPC client, 'n:<number>' for another burner, and 'sys' for network notices (reputation tier changes, SIM swaps). time is the in-game minute of the day the text was sent (0 to 1439), or nil.

A destroyed phone has no report.

Here's what report.text looks like:

TRAP PHONE 555-1234
Registered owner: unknown
Contacts (2):
  - 555-9876
  - Tasha (3 deals)
Booked meets (1):
  - 21:30 Tasha at Grove St / Forum Dr
Messages:
  [Tasha]
    20:12 <: u around? need 2 grams. got $40
    20:13 >: $55
    20:13 <: meet me in the middle. $47
    20:14 >: ok, $47 works
    20:14 <: bet
    20:14 <: when can u meet
    20:15 >: meet at 21:30
    20:15 <: see u at Grove St / Forum Dr. 21:30
  [555-9876]
    19:40 >: grove tonight

The shipped oxide-police bridge

server/integrations/evidence/oxide_police.lua in the resource folder is the whole bridge for oxide-police:

server/integrations/evidence/oxide_police.lua
if GetResourceState('oxide-police') == 'missing' then return end

local police = exports['oxide-police']

EvidenceBackend.Register('oxide-police', {
    AttachReport = function(src, caseRef, report)
        -- Case entries are a TEXT column (64 KB).
        local ok, result = police:AttachCaseEvidence(src, caseRef, report.text:sub(1, 60000))
        if ok then return true end
        return false, type(result) == 'string' and result or 'rejected'
    end,
})
  • The first line makes the file do nothing on servers without oxide-police.
  • It attaches the plain-text report as a case entry. oxide-police checks the officer is on duty and the case belongs to their department, and returns its own message ("Case not found", for example) if not. A refusal without a message shows "Not sent".
  • It needs an oxide-police version with the AttachCaseEvidence export. Version 1.9.0 and older don't have it.

Use it as a starting point for your own.

Worked Example: A Bridge For Your MDT

This example connects a made-up MDT called my_mdt. Pretend its documentation says it has two server exports:

  • AddCaseNote(caseNumber, text, officerSource) returns true, or false and a reason
  • FlagPhoneNumber(number, note)

Your MDT's real export names and arguments will be different. Look them up in its documentation first. If it has no export for adding to a case, ask its author; a bridge can only do what the MDT lets other resources do.

Create the file

In oxide-drugselling/server/integrations/evidence/, create my_mdt.lua. Give it its own name; don't edit oxide_police.lua.

Write the bridge

server/integrations/evidence/my_mdt.lua
-- Evidence bridge for my_mdt: attaches seized-phone reports to cases and flags traced numbers.

-- Do nothing on servers that don't have my_mdt.
if GetResourceState('my_mdt') == 'missing' then return end

local mdt = exports['my_mdt']

-- my_mdt notes hold up to 10,000 characters.
local NOTE_LIMIT = 10000

EvidenceBackend.Register('my_mdt', {
    AttachReport = function(src, caseRef, report)
        local text = report.text
        if #text > NOTE_LIMIT then
            text = text:sub(1, NOTE_LIMIT - 20) .. '\n[report trimmed]'
        end

        local ok, reason = mdt:AddCaseNote(caseRef, text, src)
        if ok then return true end
        return false, type(reason) == 'string' and reason or 'The MDT refused the report'
    end,

    FlagNumber = function(number, info)
        mdt:FlagPhoneNumber(number, 'Traced from a seized trap phone')
    end,
})

What each part does:

  • GetResourceState('my_mdt') == 'missing' stops the file on servers where my_mdt isn't installed.
  • EvidenceBackend.Register('my_mdt', ...) registers the bridge under the MDT's resource name.
  • AttachReport trims the report to what the MDT can hold, sends it, and passes the MDT's reason back to the officer if it refuses.
  • FlagNumber marks traced numbers in the MDT.

Switch it on

  1. In game, as an admin, run /drugselling settings.
  2. Switch the menu to Advanced, then open Pressure → Evidence.
  3. Under Evidence bridges, press Add, type my_mdt as the Resource, and turn Enabled on.
  4. If you also run oxide-police and want reports to go to my_mdt, remove the oxide-police row or turn it off. Only the first running bridge in the list is used.
  5. Press Save Changes.

Restart

New bridge files are loaded when oxide-drugselling starts, so restart your server. Make sure my_mdt starts before the Oxide resources, with your other scripts (see Installation → Load Order).

In the server console you should see which bridge is active:

[INFO][oxide-drugselling][evidence] evidence bridge
  {"active":"my_mdt"}

With oxide-logger the line is written in its own format, with the same active value. "active":"none" means no bridge qualified when oxide-drugselling finished starting: check the three rules in How a bridge is picked. This line is only written once, so an MDT that starts after oxide-drugselling shows as none here but is still used once it's running.

Testing Your Bridge

You need two characters (or a second player): one to own the phone, one with an evidence job.

Make an owned phone. On a character without a police job, run /drugselling kit and use the Trap Phone once. That character is now its registered owner. Text another burner or wait for a client, so there's something to read.
Seize it. Give or drop the phone to the officer character (a job in Evidence → Jobs, on duty while Only on-duty police is on).
Open it. As the officer, use the phone. After the extraction bar, the phone opens with an EVIDENCE banner.
Send it. Press Enter for the menu, choose Send report, type a case number that exists in your MDT, and press Enter.
Check the result on the phone (see the table under AttachReport) and in your MDT.
Test the trace (if you wrote FlagNumber). Open a conversation with another burner number, press Enter, choose Trace number. That other phone must be switched on, charged, carried by an online player, and not inside a no-signal area.

If the phone shows "Case system error", the server console has an evidence bridge call failed warning with the error from your function.

The oxide-drugselling:evidence:reportSent event fires after every attempt, with ok set to true or false, which is handy for logging.

Tips

  • Keep a copy of your bridge file. Updating oxide-drugselling replaces the resource folder. Copy your file back into server/integrations/evidence/ after updating.
  • What a bridge file can use: it runs on the server inside oxide-drugselling, so it has exports, GetResourceState, MySQL (from oxmysql), olink (the o-link bridge), json and locale.
  • Keep it quick. The officer's phone waits for AttachReport to finish.
  • Trim to your MDT's limits. A busy phone's report can be long. The oxide-police bridge keeps the first 60,000 characters.
  • Return sentences, not codes, when refusing. A sentence is shown to the officer as written; a single word shows as "Failed" (or as the phone's own message for that word), and no reason at all shows "Case system error".
  • Don't use the other functions of oxide-drugselling. Only EvidenceBackend.Register and the documented exports are meant for bridges.

Next Steps