board.lua

by ShreksHellraiser
539 days agolua
COPY
1
-- BIMG Board written by ShreksHellraiser
2
-- This is software which will display a grid of BIMGs on a monitor.
3
-- Users can pay to upload BIMGs to the monitor and the images will be cycled out on a FIFO basis.
4
-- Users may pay more to change the palette of the monitor.
5
-- Supports animations and custom palettes
6

7
-- To easily delete something someone posts on the board run `board admin`.
8
-- While this is active tapping an image on the board instantly deletes it
9
-- BE CAREFUL!
10

11
-- You have permission to modify and redistribute this
12
-- I ask that you do not run another main instance on SC3
13
-- and instead just let all boards communicate with my main instance.
14

15
-- You are free to modify, but please keep your modifications compatible.
16
-- Any added / changed features can be noted in the instance_string variable
17
-- as this is displayed upon \bimgboard [id]
18

19
settings.define("board.privatekey", { description = "Krist private key", type = "string" })
20
settings.define("board.price", { description = "Base image price", type = "number" })
21
settings.define("board.pal_price", { description = "Palette applied price", type = "number" })
22
settings.define("board.address", { description = "Krist address/name", type = "string" })
23
settings.define("board.chatbox", { description = "Chatbox command", type = "string", default = "bimgboard" })
24
settings.define("board.width", { description = "Image cells wide", type = "number" })
25
settings.define("board.height", { description = "Image cells tall", type = 'number' })
26
settings.define("board.side", { description = "Monitor side", type = "string" })
27
settings.define("board.cache", { description = "Folder for bimg cache", type = "string", default = "disk" })
28
settings.define("board.main",
29
    {
30
        description = "If this board should take control of the chatbox interface",
31
        type = "boolean",
32
        default = false
33
    })
34
settings.define("board.id", { description = "This board's specific ID", type = "string" })
35
settings.define("board.location", { description = "This board's location description", type = "string" })
36
settings.define("board.cut",
37
    {
38
        description = "Automatically provide this percentage [0,1] of earnings to board.donate_address",
39
        type = "number",
40
        default = 0.05
41
    })
42
settings.define("board.donate_address",
43
    { description = "Address to send board.cut percentage of earnings to", type = "string", default = "[email protected]" })
44
settings.define("board.donate_minimum",
45
    { description = "Minimum donation due before a transaction will be made.", type = "number", default = 10 })
46
settings.define("board.enable_donations", { description = "Enable donations", type = "boolean", default = true })
47
settings.define("board.first_run", { type = "boolean", default = true })
48
settings.save()
49

50
if not fs.exists("ktwsl.lua") then
51
    print("Missing ktwsl, installing...")
52
    local h = assert(http.get("https://raw.githubusercontent.com/MasonGulu/msks/main/ktwsl.lua"))
53
    local f = assert(fs.open("ktwsl.lua", "w"))
54
    f.write(h.readAll())
55
    f.close()
56
    h.close()
57
end
58

59
local function assert_get(setting)
60
    local value = settings.get(setting)
61
    if not value then
62
        local details = settings.getDetails(setting)
63
        print(("Setting %s is not set."):format(setting))
64
        print(("Description: %s"):format(details.description))
65
        print(("Type %s"):format(details.type))
66
        while true do
67
            print("Please enter a value:")
68
            local input = read()
69
            if details.type == "number" then
70
                if tonumber(input) then
71
                    value = tonumber(input)
72
                    break
73
                end
74
            elseif input ~= "" then
75
                value = input
76
                break
77
            end
78
        end
79
        settings.set(setting, value)
80
        settings.save()
81
    end
82
    return value
83
end
84
local modem = assert(peripheral.find("modem", function(name, wrapped)
85
    return wrapped.isWireless()
86
end), "Attach a wireless modem!") --[[@as Modem]]
87
local privatekey = assert_get("board.privatekey")
88
local krist = require("ktwsl")("https://krist.dev", privatekey)
89
local price = assert_get("board.price")
90
local pal_price = assert_get("board.pal_price")
91
local address = assert_get("board.address")
92
local chatbox_cmd = assert_get("board.chatbox")
93
local donate_cut = settings.get("board.cut", 0.05)
94
local donation_address = settings.get("board.donation_address", "[email protected]")
95
local donate_minimum = settings.get("board.donate_minimum", 10)
96
local enable_donations = settings.get("board.enable_donations", true)
97
local id = assert_get("board.id")
98
local x, y, z
99
while not x do
100
    print("Locating..")
101
    x, y, z = gps.locate()
102
end
103
local coords = ("{%d,%d,%d}"):format(x, y, z)
104
local location = assert_get("board.location")
105
local main = settings.get("board.main", false)
106
local cache = settings.get("board.cache")
107

108
if settings.get("board.first_run") then
109
    print(("Would you like to donate %.2f%% of revenue from this board to %s? [Y/n]"):format(donate_cut * 100,
110
        donation_address))
111
    enable_donations = read():lower():sub(1, 1) ~= "n"
112
    settings.set("board.enable_donations", enable_donations)
113
    settings.set("board.first_run", false)
114
    settings.save()
115
end
116

117
local strings = require "cc.strings"
118

119
local admin = arg[1] == "admin"
120
if admin then
121
    print("Running in admin mode.")
122
    print("Tap on the screen to remove a listing.")
123
end
124

125
krist.subscribeAddress(address)
126

127
---@type Monitor
128
local device = peripheral.wrap(assert_get("board.side")) --[[@as Monitor]]
129
device.setTextScale(0.5)
130
device.clear()
131

132
local w, h = device.getSize()
133
local slot_rows = assert_get("board.height")
134
local slot_columns = assert_get("board.width")
135

136
local foot_str = "Run \\%s for info. ID %s."
137

138
local footer_height = #strings.wrap(foot_str, w)
139
local slot_h = math.floor((h - footer_height) / slot_rows)
140
local slot_w = math.floor(w / slot_columns)
141

142
local display_names = false
143

144
local slot_hp = slot_h * 3
145
local slot_wp = slot_w * 2
146

147
local file_size_limit = math.floor((fs.getCapacity(cache) / (slot_rows * slot_columns)) * 0.9)
148

149
---@type table[]
150
local bimgs = {}
151

152
---@type Window[]
153
local windows = {}
154

155
local campaigns = {}
156

157
---@type integer[]
158
local frames = {}
159

160

161
-- This data is sent to the "main" bimgboard server
162
local board_data = {
163
    location = location,
164
    coords = coords,
165
    id = id,
166
    address = address,
167
    slot_w = slot_w,
168
    slot_h = slot_h,
169
    slot_wp = slot_wp,
170
    slot_hp = slot_hp,
171
    slot_rows = slot_rows,
172
    slot_columns = slot_columns,
173
    price = price,
174
    pal_price = pal_price,
175
    -- This field is for anything you desire and is displayed to the user.
176
    -- Though, I recommend using this in cases when modifications to the software is made
177
    -- for example, listing a format you added support for.
178
    extra = ""
179
}
180

181
local instance_string = ("&6&lCycling Board %s\n"):format(id) ..
182
    ("&fFor more information on how to use this board, run \\%s.\n"):format(chatbox_cmd) ..
183
    ("Resolution: &9%u&fx&9%u&f pixels, &9%u&fx&9%u&f characters\n"):format(slot_wp, slot_hp, slot_w, slot_h) ..
184
    ("Price: &9%u&f|&9%uKST &fto %s\n"):format(price, pal_price, address) ..
185
    "&cSee something that shouldn't be here? Click the screen to show who purchased each slot."
186

187
local main_string = "**=== Cycling Board ===**\n" ..
188
    "This is a system of boards which can display user provided [BIMG](https://github.com/SkyTheCodeMaster/bimg/blob/master/spec.md) files. " ..
189
    ("Run `\\%s bimg` for information on BIMG.\n"):format(chatbox_cmd) ..
190
    ("Each board has varying size, prices, and amount of slots, these slots are cycled out on a FIFO basis. To view a specific board's information run `\\%s [id]`.\n")
191
    :format(chatbox_cmd) ..
192
    ("For information on uploading an image to a board, run `\\%s usage`.\n"):format(chatbox_cmd) ..
193
    ("To see all currently running boards, run `\\%s list`.\n"):format(chatbox_cmd) ..
194
    ("For palette information run `\\%s palette`.\n"):format(chatbox_cmd)
195

196
local main_palette_string = "**=== Cycling Board Palette Info ===**\n" ..
197
    "A monitor can display multiple images, but only 16 colors. Those 16 colors are assignable, but at the cost of invalidating all images.\n" ..
198
    "For this reason there are 2 prices. The first indicates adding an image, the second indicates adding an image and changing the palette to match.\n" ..
199
    "Since it is cheaper not to change the palette, you can opt to make your image fit the current palette.\n" ..
200
    ("To take advantage of the current palette you can get a monitor's current palette by running `\\%s [id] palette`. ")
201
    :format(chatbox_cmd) ..
202
    ("The string provided can be placed directly into the BIMG Generator's custom palette field.\n"):format(chatbox_cmd)
203

204
local main_usage_string = "**=== Cycling Board Usage ===**\n" ..
205
    "To upload an image to the board you will first need to host your image somewhere. " ..
206
    "This board supports downloading a URL's contents directly, or using a `p.sc3.io` paste code. Downloads are done in binary mode.\n" ..
207
    "Once your image is uploaded somewhere, you may send a payment to the board. There are two metadata fields that are relavant\n" ..
208
    "* url - Set this to your raw URL, or your p.sc3.io paste ID.\n" ..
209
    "* pal - Set this to true if you want your image's palette to be applied, the price will be higher.\n" ..
210
    "For example, for an image hosted at paste ID `44wbJ6h33y` one could run `/pay [email protected] 10 url=44wbJ6h33y;pal=true`."
211

212
local main_bimg_string = "**=== Cycling Board BIMG Info ===**\n" ..
213
    "There exist several tools for converting images to [BIMG](https://github.com/SkyTheCodeMaster/bimg/blob/master/spec.md).\n" ..
214
    "* [BIMG Generator](https://github.com/MasonGulu/BIMG-Generator) Java; Supports GIFs, high quality dithering, user-defined palettes, text/binary exporting.\n" ..
215
    "* [Sanjuuni](https://github.com/MCJack123/sanjuuni) C++; Supports images, videos, high quality dithering, text exporting.\n" ..
216
    "* [Sanjuuni UI](https://github.com/MCJack123/sanjuuni-ui) Sanjuuni, but with QT GUI.\n" ..
217
    "* [Online BIMG Generator](https://masongulu.github.io/js-bimg-generator/) JS; Supports user-defined palettes, low quality dithering, text exporting.\n" ..
218
    "BIMG supports animations and custom palettes. Since a monitor can only display one palette changing the palette is destructive to existing images. For this reason custom palettes cost more.\n" ..
219
    "Some of these converters may export a binary file. This is okay, as long as you upload to a host that supports binary files. Pastebin and p.sc3.io do not."
220

221
local pow_2 = {}
222
for i = 0, 15 do
223
    pow_2[i] = 2 ^ i
224
end
225

226
local function get_palette_string(palette)
227
    local str = "The current palette is `["
228
    for i = 0, 15 do
229
        local pstr = palette[i][1]
230
        if palette[i][2] then
231
            pstr = colors.packRGB(palette[i][1], palette[i][2], palette[i][3])
232
        end
233
        str = str .. pstr
234
        if i ~= 15 then
235
            str = str .. ","
236
        end
237
    end
238
    str = str .. "]`"
239
    return str
240
end
241

242
-- "Pay \164%u to %s with url=<url | p.sc3.io id> to add your image " ..
243
--     "or \164%u and add pal=true to apply the palette from your image (example: /pay user 10 url=123;pal=true).\n" ..
244
--     "Accepts BIMGs using default palette, %ux%u pixels, %ux%u chars.\n" ..
245
--     "Touch monitor to toggle names."
246

247
local footer = strings.wrap(foot_str:format(chatbox_cmd, id), w)
248

249

250
---@param filename string
251
---@return table?
252
local function load_file(filename)
253
    local f = fs.open(filename, "r")
254
    if not f then
255
        return
256
    end
257
    local data = textutils.unserialise(f.readAll() --[[@as string]])
258
    f.close()
259
    return data --[[@as table]]
260
end
261

262
local function save_file(filename, data)
263
    local f = assert(fs.open(filename, "w"))
264
    f.write(textutils.serialise(data, { compact = true }))
265
    f.close()
266
end
267

268
---@type integer
269
local undonated_earnings = load_file("undonated_earnings.txt") --[[@as integer]] or 0
270

271
---@type {name: string, url: string, palette: boolean?}[]
272
campaigns = load_file("campaigns.txt") or {}
273

274
---@param url string
275
---@return string|nil
276
local function download_url(url)
277
    local f = http.get(url, nil, true)
278
    if not f then
279
        return
280
    end
281
    local data = f.readAll()
282
    f.close()
283
    return data
284
end
285

286
---@return table?
287
local function download_image(url)
288
    -- todo check for p.sc3.io codes
289
    if string.match(url, "^[%a%d]+$") then
290
        -- p.sc3.io
291
        local f = http.get("https://p.sc3.io/api/v1/pastes/" .. url)
292
        if not f then
293
            print("not a valid p.sc3.io code")
294
            return
295
        end
296
        local info = textutils.unserialiseJSON(f.readAll() --[[@as string]])
297
        f.close()
298
        if not info then
299
            print("invalid response")
300
            return
301
        end
302
        url = info.url -- fall through
303
        print("found p.sc3.io url", url)
304
    end
305
    local d = download_url(url)
306
    if not d then
307
        print("not a valid url")
308
        return
309
    end
310
    return textutils.unserialise(d) --[[@as table?]]
311
end
312
local current_palette
313

314
local function apply_palette(palette, dev)
315
    dev = dev or device
316
    for k, v in pairs(palette) do
317
        dev.setPaletteColor(pow_2[k], table.unpack(v))
318
    end
319
end
320

321
local function get_high_contrast()
322
    local brightest, darkest = 0, 255
323
    local brightest_color, darkest_color
324
    local function _ca(r, g, b)
325
        return 255 * ((r + g + b) / 3)
326
    end
327
    for i = 0, 15 do
328
        local color_average
329
        if current_palette[i][2] then
330
            color_average = _ca(table.unpack(current_palette[i]))
331
        else
332
            color_average = _ca(colors.unpackRGB(current_palette[i][1]))
333
        end
334
        if color_average < darkest then
335
            darkest = color_average
336
            darkest_color = 2 ^ i
337
        end
338
        if color_average > brightest then
339
            brightest = color_average
340
            brightest_color = 2 ^ i
341
        end
342
    end
343
    return brightest_color, darkest_color
344
end
345
local fg, bg
346

347
---@param i integer
348
local function render_bimg(i)
349
    local win, bimg, name = windows[i], bimgs[i], campaigns[i].name
350
    win.setVisible(false)
351
    apply_palette(current_palette, win)
352
    win.clear()
353
    frames[i] = (frames[i] or 0) + 1
354
    if frames[i] > #bimg then
355
        frames[i] = 1
356
    end
357
    for yPos, v in pairs(bimg[frames[i]]) do
358
        if type(yPos) == "number" then
359
            win.setCursorPos(1, yPos)
360
            win.blit(table.unpack(v))
361
        end
362
    end
363
    if display_names then
364
        win.setTextColor(fg)
365
        win.setBackgroundColor(bg)
366
        local sname = strings.wrap(name, slot_w)
367
        for line, s in ipairs(sname) do
368
            win.setCursorPos(1, line)
369
            win.write(s)
370
        end
371
    end
372
    win.setVisible(true)
373
end
374

375

376
for column = 1, slot_columns do
377
    for row = 1, slot_rows do
378
        local i = column + ((row - 1) * slot_columns)
379
        local x = 1 + ((column - 1) * slot_w)
380
        local y = 1 + ((row - 1) * slot_h)
381
        windows[i] = window.create(device, x, y, slot_w, slot_h)
382
    end
383
end
384

385
local function load_bimgs()
386
    for i = 1, slot_rows * slot_columns do
387
        if campaigns[i] then
388
            local fn = fs.combine(cache, tostring(i))
389
            if fs.exists(fn) then
390
                bimgs[i] = load_file(fn)
391
            else
392
                bimgs[i] = download_image(campaigns[i].url)
393
            end
394
            frames[i] = 1
395
        end
396
    end
397
end
398
load_bimgs()
399

400
local function save_bimgs()
401
    for k, v in pairs(fs.list(cache)) do
402
        if not fs.isDir(v) then
403
            fs.delete(v)
404
        end
405
    end
406
    for i = 1, slot_rows * slot_columns do
407
        local fn = fs.combine(cache, tostring(i))
408
        if bimgs[i] then
409
            save_file(fn, bimgs[i])
410
        end
411
    end
412
end
413
save_bimgs()
414

415
local function get_palette()
416
    if fs.exists("palette") then
417
        return load_file("palette")
418
    else
419
        for i, v in ipairs(campaigns) do
420
            if v.palette then
421
                local bimg = bimgs[i] or download_image(v.url)
422
                bimgs[i] = bimg
423
                print("got palette", i)
424
                local palette = bimg.palette or bimg[1].palette
425
                save_file("palette", palette)
426
                return palette
427
            end
428
        end
429
    end
430
    local palette = {}
431
    -- default palette
432
    print("default palette")
433
    for i, v in pairs(pow_2) do
434
        palette[i] = { term.nativePaletteColor(v) }
435
    end
436
    save_file("palette", palette)
437
    return palette
438
end
439

440
local function draw_screen()
441
    current_palette = get_palette()
442
    apply_palette(current_palette)
443
    fg, bg = get_high_contrast()
444
    device.setTextColor(fg)
445
    device.setBackgroundColor(bg)
446
    device.clear()
447
    for i = 1, slot_rows * slot_columns do
448
        if bimgs[i] then
449
            render_bimg(i)
450
        end
451
    end
452
    device.setTextColor(fg)
453
    device.setBackgroundColor(bg)
454
    for l = 1, #footer do
455
        device.setCursorPos(1, h - #footer + (l - 1))
456
        device.write(footer[l])
457
    end
458
    if admin then
459
        device.setCursorPos(1, h - #footer)
460
        device.clearLine()
461
        device.write("ADMIN MODE")
462
    end
463
    device.setCursorPos(1, 1)
464
end
465
draw_screen()
466

467

468
local function add_campaign(name, url, data, use_palette)
469
    table.insert(campaigns, 1, { name = name, url = url, palette = use_palette })
470
    table.insert(bimgs, 1, data)
471
    if use_palette then
472
        fs.delete("palette")
473
    end
474
    save_bimgs()
475
    draw_screen()
476
    save_file("campaigns.txt", campaigns)
477
end
478

479
local function remove_campaign(index)
480
    table.remove(campaigns, index)
481
    table.remove(bimgs, index)
482
    save_bimgs()
483
    load_bimgs()
484
    draw_screen()
485
    save_file("campaigns.txt", campaigns)
486
end
487

488
local function validate_bimg(bimg)
489
    if type(bimg) ~= "table" then
490
        print("not a table")
491
        return false, "Malformed file"
492
    end
493
    if not type(bimg[1] == "table") then
494
        print("no first frame")
495
        return false, "No first frame"
496
    end
497
    for yPos, v in pairs(bimg[1]) do
498
        if type(yPos) == "number" then
499
            if #v < 3 then
500
                print("invalid frame contents")
501
                return false, "Invalid BIMG frame contents"
502
            end
503
        end
504
    end
505
    -- file size validation
506
    local s = textutils.serialise(bimg, { compact = true })
507
    if #s > file_size_limit then
508
        print("BIMG too large")
509
        return false, ("BIMG is too large. Maximum file size is %u."):format(file_size_limit)
510
    end
511
    print("valid bimg")
512
    return true
513
end
514

515
local function check_donation()
516
    local donation = undonated_earnings * donate_cut
517
    if math.floor(donation) == math.ceil(donation) and donation > donate_minimum then
518
        -- send a transaction
519
        krist.makeTransaction(donation_address, donation, ("message=Donation cut from bimgboard %s"):format(id))
520
        undonated_earnings = 0
521
        save_file("undonated_earnings.txt", undonated_earnings)
522
    end
523
end
524

525
local function handle_transactions()
526
    while true do
527
        local e = table.pack(os.pullEventRaw())
528
        if e[1] == "krist_transaction" and e[2] == address then
529
            print("got transaction")
530
            local metadata = krist.parseMetadata(e[5].metadata)
531
            local url_data = metadata.url and download_image(metadata.url)
532
            print(metadata.url, url_data)
533
            local price_owed = price
534
            if metadata.pal == "true" then
535
                price_owed = pal_price
536
            end
537
            local valid, reason
538
            if url_data and e[4] >= price_owed then
539
                valid, reason = validate_bimg(url_data)
540
            end
541
            if valid then
542
                if e[4] > price_owed then
543
                    -- refund
544
                    krist.makeTransaction(e[3], e[4] - price_owed,
545
                        ("return=%s;message=%s"):format(address, "You paid too much"))
546
                end
547
                if enable_donations then
548
                    undonated_earnings = undonated_earnings + price_owed
549
                    save_file("undonated_earnings.txt", undonated_earnings)
550
                    check_donation()
551
                end
552
                add_campaign(e[3], metadata.url, url_data, metadata.pal == "true")
553
            else
554
                local fail_str = ("Invalid BIMG file: %s"):format(reason)
555
                if not url_data then
556
                    fail_str = "Please provide a valid url or p.sc3.io code in the url field"
557
                end
558
                if e[4] < price_owed then
559
                    fail_str = ("You paid too little, owed %u"):format(price_owed)
560
                end
561
                -- invalid url
562
                krist.makeTransaction(e[3], e[4], ("return=%s;message=%s"):format(address, fail_str))
563
            end
564
        elseif e[1] == "krist_stop" then
565
            for l = 1, #footer do
566
                device.setCursorPos(1, h - #footer + (l - 1))
567
                device.clearLine()
568
                device.write(("Krist Handler Errored: %s"):format(e[2]))
569
            end
570
            error(e[2])
571
        elseif e[1] == "monitor_touch" then
572
            if admin then
573
                -- delete a campaign
574
                local campaign = math.floor(e[3] / slot_w) + 1 + (math.floor(e[4] / slot_h) * slot_columns)
575
                remove_campaign(campaign)
576
            end
577
            display_names = not display_names
578
            draw_screen()
579
        elseif e[1] == "terminate" then
580
            krist.stop()
581
            error("Terminated")
582
        end
583
    end
584
end
585

586
local port = 25799
587
modem.open(port)
588
local function handle_modem()
589
    while true do
590
        local _, _, channel, reply, message, dist = os.pullEvent("modem_message")
591
        if message == "list" then
592
            modem.transmit(reply, port, board_data)
593
        elseif type(message) == "table" and message[1] == "palette" and message[2] == id then
594
            modem.transmit(reply, port, { palette = get_palette_string(current_palette), id = id })
595
        elseif type(message) == "table" and message[1] == "instance_string" and message[2] == id then
596
            modem.transmit(reply, port, { instance_string = instance_string, id = id })
597
        end
598
    end
599
end
600

601
local function get_all_monitors()
602
    print("getting monitors")
603
    modem.transmit(port, port, "list")
604
    local timer = os.startTimer(2)
605
    local monitors = {}
606
    monitors[id] = board_data
607
    while true do
608
        local event, t, channel, reply, message, dist = os.pullEvent()
609
        if event == "modem_message" and type(message) == "table" and message.id then
610
            print("got monitor", message.id)
611
            monitors[message.id] = message
612
        elseif event == "timer" and t == timer then
613
            print("done getting monitors")
614
            return monitors -- exit condition
615
        end
616
    end
617
end
618

619
local function get_palette_of(tid)
620
    if tid == id then
621
        return get_palette_string(current_palette)
622
    end
623
    modem.transmit(port, port, { "palette", tid })
624
    local timer = os.startTimer(2)
625
    while true do
626
        local event, t, channel, reply, message, dist = os.pullEvent()
627
        if event == "modem_message" and type(message) == "table" and message.id == tid and message.palette then
628
            return message.palette
629
        elseif event == "timer" and t == timer then
630
            return nil
631
        end
632
    end
633
end
634

635
local function get_instance_string_of(tid)
636
    if tid == id then
637
        return instance_string
638
    end
639
    modem.transmit(port, port, { "instance_string", tid })
640
    local timer = os.startTimer(2)
641
    while true do
642
        local event, t, channel, reply, message, dist = os.pullEvent()
643
        if event == "modem_message" and type(message) == "table" and message.id == tid and message.instance_string then
644
            return message.instance_string
645
        elseif event == "timer" and t == timer then
646
            return nil
647
        end
648
    end
649
end
650

651
local function handle_commands()
652
    assert(main, "Attempt to handle chatbox commands when not main bimgboard!")
653
    assert(chatbox.isConnected(), "Chatbox isn't connected!")
654
    while true do
655
        local e = { os.pullEventRaw("command") }
656
        if e[3] == chatbox_cmd then
657
            local user = e[2]
658
            local args = e[4]
659
            if not args[1] then
660
                print("general help")
661
                chatbox.tell(user, main_string, chatbox_cmd, nil, "markdown")
662
            elseif args[1] == "bimg" then
663
                print("bimg help")
664
                chatbox.tell(user, main_bimg_string, chatbox_cmd, nil, "markdown")
665
            elseif args[1] == "usage" then
666
                print("usage help")
667
                chatbox.tell(user, main_usage_string, chatbox_cmd, nil, "markdown")
668
            elseif args[1] == "palette" then
669
                print("palette help")
670
                chatbox.tell(user, main_palette_string, chatbox_cmd, nil, "markdown")
671
            else
672
                local monitors = get_all_monitors()
673
                print(textutils.serialise(monitors))
674
                if monitors[args[1]] then
675
                    print("listing monitor information")
676
                    if args[2] == "palette" then
677
                        local palette = get_palette_of(args[1])
678
                        if palette then
679
                            chatbox.tell(user, palette, chatbox_cmd, nil, "markdown")
680
                        end
681
                    else
682
                        local instance = get_instance_string_of(args[1])
683
                        if instance then
684
                            chatbox.tell(user, instance, chatbox_cmd, nil, "format")
685
                        end
686
                    end
687
                elseif args[1] == "list" then
688
                    print("listing")
689
                    local response = "&9=== BIMG Monitor Listing ===&f\n"
690
                    for _, v in pairs(monitors) do
691
                        response = response ..
692
                            ("ID %s @ %s %s. Resolution &9%u&fx&9%u&f pixels. Price &9%u&f|&9%uKST&f.\n"):format(v.id,
693
                                v.coords, v.location, v.slot_wp, v.slot_hp, v.price, v.pal_price)
694
                    end
695
                    chatbox.tell(user, response, chatbox_cmd, nil, "format")
696
                end
697
            end
698
        end
699
    end
700
end
701

702
local function handle_frames()
703
    while true do
704
        sleep(0.2)
705
        draw_screen()
706
    end
707
end
708

709
local to_execute = { handle_transactions, krist.start, handle_frames }
710

711
if main then
712
    to_execute[#to_execute + 1] = handle_commands
713
else
714
    to_execute[#to_execute + 1] = handle_modem
715
end
716

717
parallel.waitForAny(table.unpack(to_execute))
718