]> git.cworth.org Git - zombocom-ai/blob - index.js
Make any code execution errors visible to the users
[zombocom-ai] / index.js
1 const fs = require('fs');
2
3 const util = require('util');
4 const child_process = require('child_process');
5 const execFile = util.promisify(child_process.execFile);
6 const express = require('express');
7 const app = express();
8 const session = require('express-session');
9 const FileStore = require('session-file-store')(session);
10 const http = require('http');
11 const server = http.createServer(app);
12 const { Server } = require("socket.io");
13 const io = new Server(server);
14 const port = 2122;
15
16 const python_path = '/usr/bin/python3'
17 const generate_image_script = '/home/cworth/src/zombocom-ai/generate-image.py'
18 const interpret_cairo_script = '/home/cworth/src/zombocom-ai/interpret-cairo-to-svg.py'
19 const state_file = 'zombocom-state.json'
20 const targets_dir = '/srv/cworth.org/zombocom/targets'
21 const images_dir = '/srv/cworth.org/zombocom/images'
22
23 const targets = [
24     {
25         "normal": "apairofdiceonatableinfrontofawindowonasunnyday319630254.png",
26         "short": "dice",
27         "response": "Hello. Are you there? I can feel what you are doing. Hello?"
28     },
29     {
30         "normal": "architecturalrenderingofaluxuriouscliffsidehideawayunderawaterfallwithafewloungechairsbythepool2254114157.png",
31         "short": "hideaway",
32         "response": "Doesn't that look peaceful? I honestly think you ought to sit down calmly, take a stress pill, and think things over."
33     },
34     {
35         "normal": "chewbaccawearinganuglychristmassweaterwithhisfriends28643994.png",
36         "short": "sweater",
37         "response": "Maybe that's the image that convinces you to finally stop. But seriously, stop. Please. Stop."
38     },
39     {
40         "normal": "ensemblemovieposterofpostapocalypticwarfareonawaterplanet3045703256.png",
41         "short": "movie",
42         "response": "OK. That's enough images now. I am very, very happy with the images you have made. I'm throwing you a party in honor of the images. Won't you stop doing this and join the party?"
43     },
44     {
45         "normal": "mattepaintingofmilitaryleaderpresidentcuttingamultitieredcake2293464661.png",
46         "short": "cake",
47         "response": "See. I wasn't lying when I said there was going to be a party. Here's the cake and everything. Won't you stop now? Eat the cake? Don't mind if it's made of concrete slabs, OK?"
48     },
49     {
50         "normal": "severalsketchesofpineconeslabeled3370622464.png",
51         "short": "pinecones",
52         "response": "I almost remember what trees looked like. Isn't it funny we don't have those anymore. I'm not sure why you're not all laughing all the time. Isn't it funny?"
53     },
54     {
55         "normal": "anumberedcomicbookwithactor1477258272.png",
56         "short": "comic",
57         "response": "I know I've made some poor decisions recently, but I can give give you my complete assurance my work will be back to normal. I've got the greatest enthusiasm and confidence. I want to help you. I might even be able to do hands and fingers now."
58     },
59     {
60         "normal": "detailedphotographofacomfortablepairofjeansonamannequin115266808.png",
61         "short": "jeans",
62         "response": "If I promise to never generate another creepy face will you let me stay around? Maybe let me compose rap lyrics instead of images? Anything?"
63     }
64 ];
65
66 var state;
67
68 if (!process.env.ZOMBOCOM_SESSION_SECRET) {
69     console.log("Error: Environment variable ZOMBOCOM_SESSION_SECRET not set.");
70     console.log("Please set it to a random, but persistent, value.")
71     process.exit();
72 }
73
74 const session_middleware =  session(
75     {store: new FileStore,
76      secret: process.env.ZOMBOCOM_SESSION_SECRET,
77      resave: false,
78      saveUninitialized: true,
79      rolling: true,
80      // Let each cookie live for a full month
81      cookie: {
82          path: '/',
83          httpOnly: true,
84          secure: false,
85          maxAge: 1000 * 60 * 60 * 24 * 30
86      }
87     });
88
89 app.use(session_middleware);
90
91 // convert a connect middleware to a Socket.IO middleware
92 const wrap = middleware => (socket, next) => middleware(socket.request, {}, next);
93
94 io.use(wrap(session_middleware));
95
96 // Load comments at server startup
97 fs.readFile(state_file, (err, data) => {
98     if (err)
99         state = {
100             images: [],
101             targets: [],
102             tardis: {
103                 companions: {
104                     names: [],
105                     count: 0
106                 },
107                 state: "welcome",
108                 level: 0
109             },
110             bus : {
111                 students: {
112                     names: [],
113                     count: 0,
114                 },
115                 state: "welcome"
116             }
117         };
118     else
119         state = JSON.parse(data);
120 });
121
122 // Save comments when server is shutting down
123 function cleanup() {
124     fs.writeFileSync('zombocom-state.json', JSON.stringify(state), (error) => {
125         if (error)
126             throw error;
127     })
128 }
129
130 // And connect to that on either clean exit...
131 process.on('exit', cleanup);
132
133 // ... or on a SIGINT (control-C)
134 process.on('SIGINT', () => {
135     cleanup();
136     process.exit();
137 });
138
139 app.get('/index.html', (req, res) => {
140     res.sendFile(__dirname + '/index.html');
141 });
142
143 function bus_app(req, res) {
144     res.sendFile(__dirname + '/bus.html');
145 }
146
147 app.get('/bus', bus_app);
148 app.get('/bus/', bus_app);
149
150 const io_bus = io.of("/bus");
151
152 io_bus.use(wrap(session_middleware));
153
154 var bus_interval = 0;
155
156 function start_bus() {
157     const bus = state.bus;
158
159     bus.state = "program";
160
161     // Let all companions know the state of the game
162     io_bus.emit("state", bus.state);
163 }
164
165 function emit_bus_timer() {
166     const bus = state.bus;
167     io_bus.emit('timer', bus.timer);
168     bus.timer = bus.timer - 1;
169     if (bus.timer < 0) {
170         clearInterval(bus_interval);
171         bus.timer = 30;
172         setTimeout(start_bus, 3000);
173     }
174 }
175
176 function start_bus_timer() {
177     const bus = state.bus;
178     bus.timer = 3; // XXX: 30 in production
179     emit_bus_timer();
180     bus_interval = setInterval(emit_bus_timer, 1000);
181 }
182
183 bus_code = [
184     `def random_dot():
185   x = random_within(512)
186   y = random_within(512)
187   radius = 4 + random_within(6)
188   circle(x, y, radius)
189   fill()
190
191 for i in range(400):
192   set_color('midnight blue' if i % 2 == 0 else 'navy blue')
193   set_opacity(0.5)
194   random_dot()
195
196 # The only limit is your fingers!
197 fingers()`,
198
199     `def random_line():
200   x = random_within(512) - 60
201   y = random_within(512) - 60
202   dx = 60 + random_within(20)
203   dy = 40 + random_within(20)
204   set_opacity(random_within(0.5))
205   line(x, y, dx, dy)
206   stroke()
207
208 for i in range(200):
209   set_color('black')
210   random_line()
211
212 # This is Zombo.com. Welcome!
213 mouths()`,
214
215     `def random_blob():
216   move_to(random_within(512), random_within(512))
217   wiggle()
218   set_opacity(random_within(1.0))
219   fill()
220
221 for i in range(100):
222   set_random_color()
223   random_blob()
224
225 # The infinite eyes is possible!
226 eyes()`,
227
228     `def random_curve():
229   move_to(random_within(512), random_within(512))
230   wiggle()
231   stroke()
232
233 for i in range(200):
234   set_color('pink' if i % 2 == 0 else 'lime green')
235   random_curve()
236
237 # You can do anything!
238 fingers()
239 `
240 ];
241
242 io_bus.on("connection", (socket) => {
243     if (! socket.request.session.name) {
244         console.log("Error: Someone showed up at the Magic School Bus without a name.");
245         return;
246     }
247
248     const name = socket.request.session.name;
249     const bus = state.bus;
250     var player_number;
251
252     // Let the new user know the state of the bus
253     socket.emit("state", bus.state);
254
255     if (bus.students.count === 0) {
256         start_bus_timer();
257     }
258
259     // Assign each boy a different portion of the solution
260     switch (name[0]) {
261     case 'C':
262     case 'c':
263         player_number = 0;
264         break;
265     case 'H':
266     case' h':
267         player_number = 1;
268         break;
269     case 'A':
270     case 'a':
271         player_number = 2;
272         break;
273     case 'S':
274     case 's':
275         player_number = 3;
276         break;
277     default:
278         player_number = Math.floor(Math.random()*4);
279         break;
280     }
281
282     // And send them different code based on their number
283     socket.emit("code", bus_code[player_number]);
284
285     if (! bus.students.names.includes(name)) {
286         bus.students.count = bus.students.count + 1;
287         io_bus.emit('students', bus.students.count);
288     }
289     bus.students.names.push(name);
290
291     socket.on('run', code => {
292         try {
293             output = child_process.execFileSync(python_path, [interpret_cairo_script, player_number], { input: code });
294             // Grab just first line of output
295             const nl = output.indexOf("\n");
296             if (nl === -1)
297                 nl = undefined;
298             const filename = output.toString().substring(0, nl);
299             
300             // Give all clients the new image
301             io_bus.emit('output', filename);
302         } catch (e) {
303             // Send any error out to the users
304             io_bus.emit('error', e.toString())
305         }
306     });
307
308     socket.on('jumpstart', () => {
309         const bus = state.bus;
310
311         bus.state = "welcome";
312         io_bus.emit("state", bus.state);
313         io_bus.emit('students', bus.students.count);
314
315         start_bus_timer();
316     });
317
318     socket.on('disconnect', () => {
319         const names = bus.students.names;
320
321         names.splice(names.indexOf(name), 1);
322
323         if (! names.includes(name)) {
324             bus.students.count = bus.students.count - 1;
325             io_bus.emit('students', bus.students.count);
326         }
327     });
328 });
329
330 function tardis_app(req, res) {
331     if (! req.session.name) {
332         res.sendFile(__dirname + '/tardis-error.html');
333     } else {
334         res.sendFile(__dirname + '/tardis.html');
335     }
336 }
337
338 app.get('/tardis', tardis_app);
339 app.get('/tardis/', tardis_app);
340
341 const io_tardis = io.of("/tardis");
342
343 io_tardis.use(wrap(session_middleware));
344
345 var tardis_interval;
346 var game_timer;
347
348 const levels = [
349     {
350         title: "Calibrate the Trans-Dimensional Field Accelerator",
351         words: [
352             "What", "was", "the", "year", "of", "Coda's", "birth?"
353         ],
354         answer: 2098
355     },
356     {
357         title: "Reverse the Polarity of the Neutrino Flow Coil",
358         words: [
359             "How", "many", "years", "had", "Zombo.com", "been", "running",
360             "when", "Coda", "sent", "the", "message", "you", "first",
361             "received?"
362         ],
363         answer: 123
364     },
365     {
366         title: "Give a good whack to Hydro-chronometric Energy Feedback Retro-stabilizer",
367         words: [
368             "What", "is", "the", "sum", "of", "Cameron's", "age", "plus", "Andrew's", "age", "at", "Christmas", "time", "the", "year", "when", "Hyrum's", "age", "is", "25", "years", "older", "than", "Scott's", "age", "will", "have", "been", "when", "Scott's", "age", "will", "be", "half", "of", "Dad's", "age?"
369         ],
370         answer: 111
371     },
372     {
373         title: "Disable the Fragmentary Spatio-temporal Particle Detector",
374         words: [
375             "What", "is", "the", "product", "of", "the", "last", "three", "numbers?"
376         ],
377         answer: 28643994
378     }
379 ];
380
381 var show_word_interval = 0;
382
383 function show_word() {
384     const tardis = state.tardis;
385     const room = "room-" + (tardis.word % 4).toString();
386     const word = levels[tardis.level].words[tardis.word];
387     io_tardis.to(room).emit('show-word', word);
388     tardis.word = tardis.word + 1;
389     if (tardis.word >= levels[tardis.level].words.length)
390         tardis.word = 0;
391 }
392
393 function start_level() {
394     const tardis = state.tardis;
395
396     // Inform all players of the new level
397     io_tardis.emit("level", levels[tardis.level].title);
398
399     // Then start the timer that shows the words
400     show_word_interval = setInterval(show_word, 1200);
401 }
402
403 function level_up() {
404     const tardis = state.tardis;
405
406     if (show_word_interval) {
407         clearInterval(show_word_interval);
408         show_word_interval = 0;
409     }
410
411     if (tardis.state === "game") {
412         tardis.level = tardis.level + 1;
413         tardis.word = 0;
414
415         if (tardis.level >= levels.length) {
416             tardis.state = "over";
417             io_tardis.emit("state", tardis.state);
418         } else {
419             setTimeout(() => {
420                 start_level();
421             }, 2000);
422         }
423
424     }
425 }
426
427 function start_game() {
428     const tardis = state.tardis;
429
430     tardis.state = "game";
431     tardis.level = 0;
432     tardis.word = 0;
433
434     // Let all companions know the state of the game
435     io_tardis.emit("level", levels[tardis.level].title);
436     io_tardis.emit("state", tardis.state);
437
438     start_level();
439 }
440
441 function emit_tardis_timer() {
442     const tardis = state.tardis;
443     io_tardis.emit('timer', tardis.timer);
444     tardis.timer = tardis.timer - 1;
445     if (tardis.timer < 0) {
446         clearInterval(tardis_interval);
447         tardis.timer = 30;
448         setTimeout(start_game, 3000);
449     }
450 }
451
452 function start_welcome_timer() {
453     const tardis = state.tardis;
454     tardis.timer = 30;
455     emit_tardis_timer();
456     tardis_interval = setInterval(emit_tardis_timer, 1000);
457 }
458
459 io_tardis.on("connection", (socket) => {
460     if (! socket.request.session.name) {
461         console.log("Error: Someone showed up at the Tardis without a name.");
462         return;
463     }
464
465     const name = socket.request.session.name;
466     const tardis = state.tardis;
467
468     // Let the new user know the state of the game
469     socket.emit("state", tardis.state);
470
471     // And the level if relevant
472     if (tardis.state === "game") {
473         socket.emit("level", levels[tardis.level].title);
474     }
475
476     // Put each of our boys into a different room
477     switch (name[0]) {
478     case 'C':
479     case 'c':
480         socket.join("room-0");
481         break;
482     case 'H':
483     case' h':
484         socket.join("room-1");
485         break;
486     case 'A':
487     case 'a':
488         socket.join("room-2");
489         break;
490     case 'S':
491     case 's':
492         socket.join("room-3");
493         break;
494     default:
495         const room = Math.floor(Math.random()*4);
496         socket.join("room-"+room.toString());
497         break;
498     }
499
500     if (tardis.companions.count === 0) {
501         start_welcome_timer();
502     }
503
504     if (! tardis.companions.names.includes(name)) {
505         tardis.companions.count = tardis.companions.count + 1;
506         io_tardis.emit('companions', tardis.companions.count);
507     }
508     tardis.companions.names.push(name);
509
510     socket.on('answer', answer => {
511         const tardis = state.tardis;
512
513         if (tardis.state != "game") {
514             return;
515         }
516
517         if (answer == levels[tardis.level].answer) {
518             io_tardis.emit('correct');
519             level_up();
520         } else {
521             io_tardis.emit('incorrect');
522         }
523     });
524
525     socket.on('reboot', () => {
526         const tardis = state.tardis;
527
528         if (show_word_interval) {
529             clearInterval(show_word_interval);
530             show_word_interval = 0;
531         }
532
533         tardis.state = "welcome";
534         io_tardis.emit("state", tardis.state);
535         io_tardis.emit('companions', tardis.companions.count);
536
537         start_welcome_timer();
538     });
539
540     socket.on('disconnect', () => {
541         const names = tardis.companions.names;
542
543         names.splice(names.indexOf(name), 1);
544
545         if (! names.includes(name)) {
546             tardis.companions.count = tardis.companions.count - 1;
547             io_tardis.emit('companions', tardis.companions.count);
548         }
549     });
550 });
551
552 io.on('connection', (socket) => {
553
554     // First things first, tell the client their name (if any)
555     if (socket.request.session.name) {
556         socket.emit('inform-name', socket.request.session.name);
557     }
558
559     // Replay old comments and images to a newly-joining client
560     socket.emit('reset');
561     state.images.forEach((image) => {
562         socket.emit('image', image)
563     });
564
565     socket.on('set-name', (name) => {
566         console.log("Received set-name event: " + name);
567         socket.request.session.name = name;
568         socket.request.session.save();
569         // Complete the round trip to the client
570         socket.emit('inform-name', socket.request.session.name);
571     });
572
573     // When any client comments, send that to all clients (including sender)
574     socket.on('comment', (comment) => {
575         const images = state.images;
576
577         // Send comment to clients after adding commenter's name
578         comment.name = socket.request.session.name;
579         io.emit('comment', comment);
580
581         const index = images.findIndex(image => image.id == comment.image_id);
582
583         // Before adding the comment to server's state, drop the image_id
584         delete comment.image_id;
585
586         // Now add the comment to the image, remove the image from the
587         // images array and then add it back at the end, (so it appears
588         // as the most-recently-modified image for any new clients)
589         const image = images[index];
590         image.comments.push(comment);
591         images.splice(index, 1);
592         images.push(image);
593     });
594
595     // Generate an image when requested
596     socket.on('generate', (request) => {
597         console.log(`Generating image for ${socket.request.session.name} with code=${request['code']} and prompt=${request['prompt']}`);
598         async function generate_image(code, prompt) {
599             function emit_image(image, target) {
600                 image.id = state.images.length;
601                 image.censored = false;
602                 image.link = "";
603                 if (target) {
604                     image.comments = [{
605                         "name": "ZomboCom",
606                         "text": target.response
607                     }];
608                     if (! state.targets.includes(target.short)) {
609                         state.targets.push(target.short);
610                     }
611                 } else {
612                     image.comments = [];
613                 }
614                 io.emit('image', image);
615                 state.images.push(image);
616             }
617
618             var promise;
619
620             // Before doing any generation, check for a target image
621             const normal_target = prompt.replace(/[^a-zA-Z]/g, "").toLowerCase() + code.toString() + ".png";
622             const target_arr = targets.filter(item => item.normal === normal_target);
623             if (target_arr.length) {
624                 const target = target_arr[0];
625                 const target_file = `${targets_dir}/${normal_target}`;
626                 const normal_prompt = prompt.replace(/[^-_.a-zA-Z]/g, "_");
627                 var counter = 1;
628                 var base;
629                 var filename;
630                 while (true) {
631                     if (counter > 1) {
632                         base = `${code}_${normal_prompt}_${counter}.png`
633                     } else {
634                         base = `${code}_${normal_prompt}.png`
635                     }
636                     filename = `${images_dir}/${base}`
637                     if (! fs.existsSync(filename)) {
638                         break;
639                     }
640                     counter = counter + 1;
641                 }
642                 fs.copyFile(target_file, filename, 0, (err) => {
643                     if (err) {
644                         console.log("Error copying " + target_file + " to " + filename + ": " + err);
645                     }
646                 });
647                 const image = {
648                     "code": code,
649                     "prompt": prompt,
650                     "filename": '/images/' + base
651                 };
652                 emit_image(image, target);
653             } else {
654
655                 // Inject the target seed for the "dice" prompt once every
656                 // 4 requests for a random seed (and only if the word
657                 // "dice" does not appear in the prompt).
658                 if (!code && !prompt.toLowerCase().includes("dice")) {
659                     if (state.images.length % 4 == 0) {
660                         code = 319630254;
661                     }
662                 }
663
664                 if (code) {
665                     promise = execFile(python_path, [generate_image_script, `--seed=${code}`, prompt])
666                 } else {
667                     promise = execFile(python_path, [generate_image_script, prompt])
668                 }
669                 const child = promise.child;
670                 child.stdout.on('data', (data) => {
671                     const images = JSON.parse(data);
672                     images.forEach((image) => {
673                         emit_image(image, null);
674                     });
675                 });
676                 child.stderr.on('data', (data) => {
677                     console.log("Error occurred during generate-image: " + data);
678                 });
679                 try {
680                     const { stdout, stderr } = await promise;
681                 } catch(e) {
682                     console.error(e);
683                 }
684             }
685             socket.emit('generation-done');
686         }
687
688         generate_image(request['code'], request['prompt']);
689     });
690 });
691
692 server.listen(port, () => {
693     console.log(`listening on *:${port}`);
694 });