-
Notifications
You must be signed in to change notification settings - Fork 39
/
index.js
3048 lines (2991 loc) · 190 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Setup, loading libraries and initial config
const fs = require('fs')
const fsPromises = require('fs').promises
const fsExists = require('fs.promises.exists')
var config = fs.existsSync('./config/.env') ? require('dotenv').config({ path:'./config/.env'}).parsed : require('dotenv').config().parsed
if (!config||!config.apiUrl||!config.basePath||!config.channelID||!config.adminID||!config.discordBotKey||!config.pixelLimit||!config.fileWatcher||!config.samplers) { throw('Please re-read the setup instructions at https://github.com/ausbitbank/stable-diffusion-discord-bot , you are missing the required .env configuration file or options') }
const Eris = require("eris")
const Constants = Eris.Constants
const Collection = Eris.Collection
const axios = require('axios')
var parseArgs = require('minimist')
const chokidar = require('chokidar')
const moment = require('moment')
const sharp = require("sharp")
const GIF = require("sharp-gif2")
const Diff = require("diff")
const log = console.log.bind(console)
function debugLog(m){if(config.showDebug){log(m)}}
function time(label){if(config.showDebugPerformance){console.time(label)}}
function timeEnd(label){if(config.showDebugPerformance){console.timeEnd(label)}}
const loop = (times, callback) => {[...Array(times)].forEach((item, i) => callback(i))}
const dJSON = require('dirty-json')
var colors = require('colors')
const debounce = require('debounce')
var jimp = require('jimp')
const FormData = require('form-data')
const io = require("socket.io-client")
const socket = io(config.apiUrl,{reconnect: true})
const ExifReader = require('exifreader')
if(config.nsfwChecksEnabled==="true"){config.nsfwChecksEnabled=true}else{config.nsfwChecksEnabled=false}
var paused=false
var queue = []
var users = []
var payments = []
dbRead()
// Shutdown gracefully
var signals = {'SIGHUP': 1,'SIGINT': 2,'SIGTERM': 15}
Object.keys(signals).forEach((signal)=>{
process.on(signal, () =>{
log('Bye! ('+signal+' '+signals[signal]+')')
process.exit(128+signals[signal])
})
})
// Setup scheduler, start repeating checks
var schedule = []
var dbScheduleFile='./config/dbSchedule.json' // flat file db for schedule
dbScheduleRead()
var cron = require('node-cron')
// hive payment checks. On startup, every 15 minutes and on a !recharge call
const hive = require('@hiveio/hive-js')
if(config.creditsDisabled==='true'){var creditsDisabled=true}else{var creditsDisabled=false}
if(config.showFilename==='true'){var showFilename=true}else{var showFilename=false}
if(config.showPreviews==='true'){var showPreviews=true}else{var showPreviews=false}
if(config.showRenderSettings==='true'){var showRenderSettings=true}else{var showRenderSettings=false}
if(config.statsAdminOnly==='true'){var statsAdminOnly=true}else{var statsAdminOnly=false}
if(config.deleteAfterPost==='true'){var deleteAfterPost=true}else{var deleteAfterPost=false}
if(config.hivePaymentAddress.length>0 && !creditsDisabled){
var hiveEndpoints = ['https://rpc.ausbit.dev','https://api.hive.blog','https://api.deathwing.me','https://api.c0ff33a.uk','https://hived.emre.sh','https://hive-api.arcange.eu','https://api.hive.blue','https://techcoderx.com','https://hive-api.3speak.tv','https://rpc.mahdiyari.info']
shuffle(hiveEndpoints)
hive.config.set('alternative_api_endpoints',hiveEndpoints)
var hiveUsd = 0.3
var lastHiveUsd = hiveUsd
getPrices()
cron.schedule('0,15,30,45 * * * *', () => { log('Checking account history every 15 minutes'.grey); checkNewPayments() })
cron.schedule('0,30 * * * *', () => { log('Updating hive price every 30 minutes'.grey); getPrices() }) // todo only get price when needed to apply credit
if(config.freeRechargeAmount&&config.freeRechargeAmount>0){cron.schedule('0 */12 * * *', () => { log('Recharging users with no credit every 12 hrs'.bgCyan.bold);freeRecharge() }) }
}
const bot = new Eris.CommandClient(config.discordBotKey, {
intents: ["guilds", "guildMessages", "messageContent", "directMessages", "guildMessageReactions", "directMessageReactions"],
description: config.botDescription||"Just a slave to the art, maaan",
owner: config.botOwner||"ausbitbank",
prefix: "!",
reconnect: 'auto',
compress: true,
getAllUsers: false,
maxShards: 'auto'
})
var defaultSize = parseInt(config.defaultSize)||512
var defaultSteps = parseInt(config.defaultSteps)||50
var defaultScale = parseFloat(config.defaultScale)||7.5
var defaultStrength = parseFloat(config.defaultStrength)||0.45
var maxSteps = parseInt(config.maxSteps)||100
var maxIterations = parseInt(config.maxIterations)||10
var defaultMaxDiscordFileSize=parseInt(config.defaultMaxDiscordFileSize)||25000000
var basePath = config.basePath
var invokePath = config.invokePath ? config.invokePath : config.basePath
var maxAnimateImages = 100 // Only will fetch most recent X images for animating
var allGalleryChannels = fs.existsSync('./config/dbGalleryChannels.json') ? JSON.parse(fs.readFileSync('./config/dbGalleryChannels.json', 'utf8')) : {}
var allNSFWChannels = fs.existsSync('./config/dbNSFWChannels.json') ? JSON.parse(fs.readFileSync('./config/dbNSFWChannels.json', 'utf8')) : {}
var nsfwWords=config.nsfwWords?.split(',')||['sex','nude','nudity','nsfw','naked','porn','fuck']
if(config.nsfwWords&&config.nsfwWords===''){nsfwWords=[]}
var rembg=config.rembg||'http://127.0.0.1:5000?url='
var defaultModel=config.defaultModel||'stable-diffusion-1.5'
var currentModel='notInitializedYet'
var defaultModelInpaint=config.defaultModelInpaint||'inpainting'
var defaultInpaintPrompt=config.defaultInpaintPrompt||'amazing'
var defaultInpaintStrength=parseFloat(config.defaultInpaintStrength)||0.75
var models=null
var lora=null
var ti=null
// load samplers from config
var samplers=config.samplers.split(',')
var samplersSlash=[]
samplers.forEach((s)=>{samplersSlash.push({name: s, value: s})})
var defaultSampler=samplers[0]
debugLog('Enabled samplers: '+samplers.join(','))
debugLog('Default sampler:'+defaultSampler)
// load our own font list from config
var fonts = config.fonts ? config.fonts.split(',') : ['Arial','Comic Sans MS','Tahoma','Times New Roman','Verdana','Lucida Console']
var fontsSlashCmd = []
fonts.forEach((f)=>{fontsSlashCmd.push({name: f,value: f})})
var rendering = false
var dialogs = {queue: null} // Track and replace our own messages to reduce spam
var failedToPost=[]
// load text files from txt directory, usable as {filename} in prompts, will return a random line from file
var randoms=[]
var randomsCache=[]
try{ // Do we move txt file folder to config as well ? Does anyone but me even customize this ? lmk
fs.readdir('txt',(err,files)=>{
if(err){log('Unable to read txt file directory'.bgRed);log(err)}
files.forEach((file)=>{
if (file.includes('.txt')){
var name=file.replace('.txt','')
randoms.push(name)
randomsCache.push(fs.readFileSync('txt/'+file,'utf-8').split(/r?\n/))
}
})
debugLog('Enabled randomisers: '+randoms.join(','))
})
}catch(err){log('Unable to read txt file directory'.bgRed);log(err)}
if(randoms.includes('prompt')){randoms.splice(randoms.indexOf('prompt'),1);randoms.splice(0,0,'prompt')} // Prompt should be interpreted first
// slash command setup - beware discord global limitations on the size/amount of slash command options
var slashCommands = [
{
name: 'dream',
description: 'Create a new image from your prompt',
options: [
{type: 3, name: 'prompt', description: 'what would you like to see ?', required: true, min_length: 1, max_length:1500 },
{type: 4, name: 'width', description: 'width of the image in pixels (250-~1024)', required: false, min_value: 256, max_value: 2048 },
{type: 4, name: 'height', description: 'height of the image in pixels (250-~1024)', required: false, min_value: 256, max_value: 2048 },
{type: 4, name: 'steps', description: 'how many steps to render for (10-250)', required: false, min_value: 5, max_value: 250 },
{type: 4, name: 'seed', description: 'seed (initial noise pattern)', required: false},
{type: 10, name: 'strength', description: 'how much noise to add to your template image (0.1-0.9)', required: false, min_value:0.01, max_value:0.99},
{type: 10, name: 'scale', description: 'how important is the prompt (1-30)', required: false, min_value:1, max_value:30},
{type: 4, name: 'number', description: 'how many would you like (1-10)', required: false, min_value: 1, max_value: 10},
{type: 5, name: 'seamless', description: 'Seamlessly tiling textures', required: false},
{type: 3, name: 'sampler', description: 'which sampler to use (default is '+defaultSampler+')', required: false, choices: samplersSlash},
{type: 11, name: 'attachment', description: 'use template image', required: false},
{type: 10, name: 'gfpgan_strength', description: 'GFPGan strength (0-1)(low= more face correction, high= more accuracy)', required: false, min_value: 0, max_value: 1},
{type: 10, name: 'codeformer_strength', description: 'Codeformer strength (0-1)(low= more face correction, high= more accuracy)', required: false, min_value: 0, max_value: 1},
{type: 3, name: 'upscale_level', description: 'upscale amount', required: false, choices: [{name: 'none', value: '0'},{name: '2x', value: '2'},{name: '4x', value: '4'}]},
{type: 10, name: 'upscale_strength', description: 'upscale strength (0-1)(smoothing/detail loss)', required: false, min_value: 0, max_value: 1},
{type: 10, name: 'variation_amount', description: 'how much variation from the original image (0-1)(need seed+not k_euler_a sampler)', required: false, min_value:0.01, max_value:1},
{type: 3, name: 'with_variations', description: 'Advanced variant control, provide seed(s)+weight eg "seed:weight,seed:weight"', required: false, min_length:4,max_length:100},
{type: 10, name: 'threshold', description: 'Advanced threshold control (0-10)', required: false, min_value:0, max_value:40},
{type: 10, name: 'perlin', description: 'Add perlin noise to your image (0-1)', required: false, min_value:0, max_value:1},
{type: 5, name: 'hires_fix', description: 'High resolution fix (re-renders twice using template)', required: false},
{type: 3, name: 'model', description: 'Change the model/checkpoint - see /models for more info', required: false, min_length: 3, max_length:40}
],
cooldown: 500,
execute: (i) => {
// get attachments
if (i.data.resolved && i.data.resolved.attachments && i.data.resolved.attachments.find(a=>a.contentType.startsWith('image/'))){
var attachmentOrig=i.data.resolved.attachments.find(a=>a.contentType.startsWith('image/'))
var attachment=[{width:attachmentOrig.width,height:attachmentOrig.height,size:attachmentOrig.size,proxy_url:attachmentOrig.proxyUrl,content_type:attachmentOrig.contentType,filename:attachmentOrig.filename,id:attachmentOrig.id}]
}else{var attachment=[]}
// below allows for the different data structure in public interactions vs direct messages
request({cmd:getCmd(prepSlashCmd(i.data.options)),userid:i.member?i.member.id:i.user.id,username:i.member?i.member.user.username:i.user.username,bot:i.member?i.member.user.bot:i.user.bot,channelid:i.channel.id,guildid:i.guildID?i.guildID:undefined,attachments:attachment})
}
},
{
name: 'random',
description: 'Show me a random prompt from the library',
options: [ {type: 3, name: 'prompt', description: 'Add these keywords to a random prompt', required: false} ],
cooldown: 500,
execute: (i) => {
var prompt=i.data.options?i.data.options[0].value+' '+getRandom('prompt'):getRandom('prompt')
request({cmd:prompt,userid:i.member?i.member.id:i.user.id,username:i.member?i.member.user.username:i.user.username,bot:i.member?i.member.user.bot:i.user.bot,channelid:i.channel.id,guildid:i.guildID?i.guildID:undefined,attachments:[]})
}
},
{
name: 'lexica',
description: 'Search lexica.art with keywords or an image url',
options: [ {type: 3, name: 'query', description: 'What are you looking for', required: true} ],
cooldown: 500,
execute: (i) => {
var query = ''
if (i.data.options) {
query+= i.data.options[0].value
if (i.member){var who=i.member}else if(i.user){var who=i.user}
log('lexica search from '+who.username)
lexicaSearch(query,i.channel.id)
}
}
},
{
name: 'help',
description: 'Learn how to use this bot',
cooldown: 500,
execute: (i) => {
help(i.channel.id)
}
},
{
name: 'models',
description: 'See what models are currently available',
cooldown: 1000,
execute: (i) => {
listModels(i.channel.id)
}
},
{
name: 'embeds',
description: 'See what embeddings are currently available',
cooldown: 1000,
execute: (i) => {
listEmbeds(i.channel.id)
}
},
{
name: 'text',
description: 'Add text overlays to an image',
options: [
{type: 3, name: 'text', description: 'What to write on the image', required: true, min_length: 1, max_length:500 },
{type: 11, name: 'attachment', description: 'Image to add text to', required: true},
{type: 3, name: 'position', description: 'Where to position the text',required: false,value: 'south',choices: [{name:'centre',value:'centre'},{name:'north',value:'north'},{name:'northeast',value:'northeast'},{name:'east',value:'east'},{name:'southeast',value:'southeast'},{name:'south',value:'south'},{name:'southwest',value:'southwest'},{name:'west',value:'west'},{name:'northwest',value:'northwest'}]},
{type: 3, name: 'color', description: 'Text color (name or hex)', required: false, min_length: 1, max_length:50 },
{type: 3, name: 'blendmode', description: 'How to blend the text layer', required: false,value:'overlay',choices:[{name:'clear',value:'clear'},{name:'over',value:'over'},{name:'out',value:'out'},{name:'atop',value:'atop'},{name:'dest',value:'dest'},{name:'xor',value:'xor'},{name:'add',value:'add'},{name:'saturate',value:'saturate'},{name:'multiply',value:'multiply'},{name:'screen',value:'screen'},{name:'overlay',value:'overlay'},{name:'darken',value:'darken'},{name:'lighten',value:'lighten'},{name:'color-dodge',value:'color-dodge'},{name:'color-burn',value:'color-burn'},{name:'hard-light',value:'hard-light'},{name:'soft-light',value:'soft-light'},{name:'difference',value:'difference'},{name:'exclusion',value:'exclusion'}] }, // should be dropdown
{type: 3, name: 'width', description: 'How many pixels wide is the text?', required: false, min_length: 1, max_length:5 },
{type: 3, name: 'height', description: 'How many pixels high is the text?', required: false, min_length: 1, max_length:5 },
{type: 3, name: 'font', description: 'What font to use', required: false,value:'Arial',choices:fontsSlashCmd},
{type: 5, name: 'extend', description: 'Extend the image?', required: false},
{type: 3, name: 'extendcolor', description: 'What color extension?', required: false, min_length: 1, max_length:10 },
],
cooldown: 500,
execute: (i) => {
var ops=i.data.options
var {text='word',position='south',color='white',blendmode='difference',width=false,height=125,font=fonts[0],extend=false,extendcolor='black'}=ops.reduce((acc,o)=>{acc[o.name]=o.value;return acc}, {})
var userid=i.member ? i.member.id : i.user.id
if (i.data.resolved && i.data.resolved.attachments && i.data.resolved.attachments.find(a=>a.contentType.startsWith('image/'))){
var attachmentOrig=i.data.resolved.attachments.find(a=>a.contentType.startsWith('image/'))
}
textOverlay(attachmentOrig.proxyUrl,text,position,i.channel.id,userid,color,blendmode,parseInt(width)||false,parseInt(height),font,extend,extendcolor)
}
},
{
name: 'background',
description: 'Remove background from an image',
options: [
{type:11,name:'attachment',description:'Image to remove background from',required:true},
{type: 3, name: 'model', description: 'Which masking model to use',required: false,value: 'u2net',choices: [{name:'u2net',value:'u2net'},{name:'u2netp',value:'u2netp'},{name:'u2net_human_seg',value:'u2net_human_seg'},{name:'u2net_cloth_seg',value:'u2net_cloth_seg'},{name:'silueta',value:'silueta'},{name:'isnet-general-use',value:'isnet-general-use'}]},
{type: 5, name: 'a', description: 'Alpha matting true/false', required: false,default:false},
{type: 4, name: 'ab', description: 'Background threshold 0-255 default 10', required: false,min_length:1,max_length:3,value:10},
{type: 4, name: 'af', description: 'Foreground threshold 0-255 default 240', required: false,value:240},
{type: 4, name: 'ae', description: 'Alpha erode size 0-255 default 10', required: false,value:10},
{type: 5, name: 'om', description: 'Mask Only true/false default false', required: false,value:false},
{type: 5, name: 'ppm', description: 'Post Process Mask true/false default false', required: false,value:false},
{type: 3, name: 'bgc', description: 'Background color R,G,B,A 0-255 default 0,0,0,0', required: false}
],
cooldown: 500,
execute: (i) => {
if (i.data.resolved && i.data.resolved.attachments && i.data.resolved.attachments.find(a=>a.contentType.startsWith('image/'))){
var attachmentOrig=i.data.resolved.attachments.find(a=>a.contentType.startsWith('image/'))
var userid=i.member ? i.member.id : i.user.id
var ops=i.data.options
debugLog(ops)
var {model='u2net',a=false,ab=10,af=240,ae=10,om=false,ppm=false,bgc='0,0,0,0'}=ops.reduce((acc,o)=>{acc[o.name]=o.value;return acc}, {})
removeBackground(attachmentOrig.proxyUrl,i.channel.id,userid,model,a,ab,af,ae,om,ppm,bgc)
}
}
}
]
// If credits are active, add /recharge and /balance otherwise don't include them
if(!creditsDisabled)
{
slashCommands.push({
name: 'recharge',
description: 'Recharge your render credits with Hive, HBD or Bitcoin over lightning network',
cooldown: 500,
execute: (i) => {if (i.member) {rechargePrompt(i.member.id,i.channel.id)} else if (i.user){rechargePrompt(i.user.id,i.channel.id)}}
})
slashCommands.push({
name: 'balance',
description: 'Check your credit balance',
cooldown: 500,
execute: (i) => {var userid=i.member?i.member.id:i.user.id;balancePrompt(userid,i.channel.id)}
})
}
// Functions
function auto2invoke(text) {
// convert auto1111 weight syntax to invokeai
// todo convert lora syntax eg <lora:add_detail:1> to withLora(add_detail,1)
const regex = /\(([^)]+):([^)]+)\)/g
return text.replaceAll(regex, function(match, $1, $2) {
return '('+$1+')' + $2
})
}
async function request(request){
// request = { cmd: string, userid: int, username: string, bot: false, channelid: int, guildid: int, attachments: {}, }
if (request.cmd.includes('{')) { request.cmd = replaceRandoms(request.cmd) } // swap randomizers
var args = parseArgs(request.cmd.split(' '),{string: ['template','init_img','sampler','text_mask','A',],boolean: ['seamless','hires_fix']}) // parse arguments //
// alias invokeai parameter names
if(args.s){args.steps=args.s}
if(args.S){args.seed=args.S}
if(args.W){args.width=args.W}
if(args.H){args.height=args.H}
if(args.C){args.scale=args.C}
if(args.A){args.sampler=args.A}
if(args.f){args.strength=args.f}
if(args.hrf){args.hires_fix=args.hrf}
// messy code below contains defaults values, check numbers are actually numbers and within acceptable ranges etc
// let sanitize all the numbers first
for (n in [args.width,args.height,args.steps,args.seed,args.strength,args.scale,args.number,args.threshold,args.perlin]){
n=n.replaceAll(/[^0-9\.]/g, '') // not affecting the actual args
}
//args.width=(args.width&&Number.isInteger(args.width)&&args.width<256)?args.width:defaultSize
if (!args.width||!Number.isInteger(args.width)||args.width<256){args.width=defaultSize}
if (!args.height||!Number.isInteger(args.height)||args.height<256){args.height=defaultSize}
if ((args.width*args.height)>config.pixelLimit) { // too big, try to compromise, find aspect ratio and use max resolution of same ratio
if (args.width===args.height){
args.width=closestRes(Math.sqrt(config.pixelLimit)); args.height=closestRes(Math.sqrt(config.pixelLimit))
} else if (args.width>args.height){
var ratio = args.height/args.width
args.width=closestRes(Math.sqrt(config.pixelLimit))
args.height=closestRes(args.width*ratio)
} else {
var ratio = args.width/args.height
args.height=closestRes(Math.sqrt(config.pixelLimit))
args.width=closestRes(args.height*ratio)
}
args.width=parseInt(args.width);args.height=parseInt(args.height)
log('compromised resolution to '+args.width+'x'+args.height)
}
if (!args.steps||!Number.isInteger(args.steps)||args.steps>maxSteps){args.steps=defaultSteps} // default 50
if (!args.seed||!Number.isInteger(args.seed)||args.seed<1||args.seed>4294967295){args.seed=getRandomSeed()}
if (!args.strength||args.strength>1||args.strength<=0){args.strength=defaultStrength}
if (!args.scale||args.scale>200||args.scale<1){args.scale=defaultScale}
if (!args.sampler){args.sampler=defaultSampler}
if (args.n){args.number=args.n}
if (!args.number||!Number.isInteger(args.number)||args.number>maxIterations||args.number<1){args.number=1}
if (!args.gfpgan_strength){args.gfpgan_strength=0}
if (!args.codeformer_strength){args.codeformer_strength=0}
if (!args.upscale_level){args.upscale_level=''}
if (!args.upscale_strength){args.upscale_strength=0.5}
if (!args.variation_amount||args.variation_amount>1||args.variation_amount<0){args.variation_amount=0}
if (!args.with_variations){args.with_variations=[]}else{log(args.with_variations)}//; args.with_variations=args.with_variations.toString()
if (!args.threshold){args.threshold=0}
if (!args.perlin||args.perlin>1||args.perlin<0){args.perlin=0}
if (!args.model||args.model===undefined||!Object.keys(models).includes(args.model)){args.model=defaultModel}else{args.model=args.model}
args.timestamp=moment()
args.prompt=sanitize(args._.join(' '))
if (args.no) args.prompt+=' ['+args.no+']'
// Check NSFW status of channel, modify prompt if open channel
// todo can we tell if the user themselves are age verified besides requesting inside nsfw channel ?
var censoredPrompt=false
try{
var channel=undefined
if(request.channelid){channel = await bot.getChannel(request.channelid)}
var channelNsfw=false
if(!channel||!request.guildid){channelNsfw=true} // If missing channel or guild completely its a DM, enable nsfw
if((channel&&Object.keys(channel).includes('nsfw'))){channelNsfw=channel.nsfw} // enforce consistency, undefined===false & probably DM
if(channelNsfw===undefined) channelNsfw=false
if(config.nsfwWords===''||config.nsfwWords===undefined||config.nsfwWords.length===0) channelNsfw=true // if wordlist disabled, skip
if(!channelNsfw&&!isPromptSFW(args.prompt)){ // NSFW words defined, SFW channel, not a DM channel, NSFW prompt
debugLog(('Censoring Prompt - Channel is nsfw: '+channelNsfw+' , Prompt is nsfw:'+!isPromptSFW(args.prompt)+' , Channel id: '+request.channelid+' , User id: '+request.userid).bgRed.black)
// todo if SFW channel, attempt to find NSFW channel for guild before falling back to filter prompts
censoredPrompt=true
args.prompt=makePromptSFW(args.prompt)
}
}catch(err){debugLog(err)}
if (args.prompt.length===0){args.prompt=getRandom('prompt');log('empty prompt found, adding random')}
args.prompt = auto2invoke(args.prompt)
var newJob={
id: queue.length+1,
status: 'new',
cmd: request.cmd,
userid: request.userid,
username: request.username,
timestampRequested: args.timestamp,
channel: request.channelid,
guild:request.guildid,
attachments: request.attachments,
seed: args.seed,
number: args.number,
width: args.width,
height: args.height,
steps: args.steps,
prompt: args.prompt,
scale: args.scale,
sampler: args.sampler,
strength: args.strength,
threshold: args.threshold,
perlin: args.perlin,
gfpgan_strength: args.gfpgan_strength,
codeformer_strength: args.codeformer_strength,
upscale_level: args.upscale_level,
upscale_strength: args.upscale_strength,
variation_amount: args.variation_amount,
with_variations: args.with_variations,
results: [],
model: args.model,
censoredPrompt: censoredPrompt
}
if(args.text_mask){newJob.text_mask=args.text_mask}
if(args.mask){newJob.text_mask=args.mask}
if(args.mask_strength){newJob.mask_strength=args.mask_strength}
if(args.invert_mask===true||args.invert_mask==='True'){newJob.invert_mask=true}else{newJob.invert_mask=false}
if(args.seamless===true||args.seamless==='True'){newJob.seamless=true}else{newJob.seamless=false}
if(args.hires_fix===true||args.hires_fix==='True'){newJob.hires_fix=true}else{newJob.hires_fix=false}
if(args.symv){newJob.symv=args.symv}
if(args.symh){newJob.symh=args.symh}
if(newJob.channel==='webhook'&&request.webhook){newJob.webhook=request.webhook}
if(creditsDisabled){newJob.cost=0}else{newJob.cost=costCalculator(newJob)}
// check if near identical job already exists unfinished in the queue for the same channel
var identicalJob = queue.find(q=>{if(q.seed===newJob.seed&&q.channel===newJob.channel&&q.prompt===newJob.prompt&&q.model===newJob.model&&['new','rendering'].includes(q.status)&&q.steps===newJob.steps&&q.sampler===newJob.sampler&&q.strength===newJob.strength&&q.attachments===newJob.attachments&&q.scale===newJob.scale&&q.width===newJob.width&&q.height===newJob.height&&q.perlin===newJob.perlin&&q.upscale_level===newJob.upscale_level&&q.with_variations===newJob.with_variations){return true}else{return false}})
if(identicalJob){
debugLog('Identical job posted by '+newJob.username+' , ignoring')
} else {
queue.push(newJob)
dbWrite() // Push db write after each new addition
}
processQueue()
}
queueStatusLock=false
async function queueStatus() {
if(queueStatusLock===true){return}else{queueStatusLock=true}
sent=false;
var renderq=queue.filter((j)=>j.status==='rendering')
var renderGps=tidyNumber((getPixelStepsTotal(renderq)/1000000).toFixed(0))
var statusMsg=''
if(renderq.length>0){
var next = renderq[0]
statusMsg+='\n:track_next:'
statusMsg+='`'+next.prompt + '`'
if(next.number!==1){statusMsg+='x'+next.number}
if(next.upscale_level!==''){statusMsg+=':mag:'}
if(next.gfpgan_strength!==0){statusMsg+=':lipstick:'}
if(next.codeformer_strength!==0){statusMsg+=':lipstick:'}
if(next.variation_amount!==0){statusMsg+=':microbe:'}
if(next.steps>defaultSteps){statusMsg+=':recycle:'}
if(next.seamless===true){statusMsg+=':knot:'}
if(next.hires_fix===true){statusMsg+=':telescope:'}
if(next.init_img && next.init_img!==''){statusMsg+=':paperclip:'}
if((next.width!==next.height)||(next.width>defaultSize)){statusMsg+=':straight_ruler:'}
statusMsg+=' :brain: **'+next.username+'** :coin:`'+costCalculator(next)+'` :fire:`'+renderGps+'`'
var renderPercent=((parseInt(progressUpdate['currentStep'])/parseInt(progressUpdate['totalSteps']))*100).toFixed(2)
if(['k_heun','k_dpm_2_a'].includes(next.sampler)){renderPercent = (renderPercent/2).toFixed(2)} // Stop buggy display of double percent for these samplers
statusMsg+='\n'+emojiProgressBar(renderPercent)+' `'+progressUpdate['currentStatus'].replace('common.status','')+'` '
if (progressUpdate['currentStatusHasSteps']===true&&!isNaN(renderPercent)){
statusMsg+='`'+renderPercent ? renderPercent+'%' : 100+'% Step '+progressUpdate['currentStep']+'/'+progressUpdate['totalSteps']+'`'
if (progressUpdate['totalIterations']>1){
statusMsg+=' Iteration `'+progressUpdate['currentIteration']+'/'+progressUpdate['totalIterations']+'`'
}
if(progressUpdate['totalSteps']&&progressUpdate['currentStep']){
statusMsg+=' '+emojiProgressBar(renderPercent)
}
}
var statusObj={content:statusMsg}
if(next&&next.channel!=='webhook'){var chan=next.channel}else{var chan=config.channelID}
if(dialogs.queue!==null){ // reuse existing dialog
if(dialogs.queue.channel.id!==next.channel){dialogs.queue.delete().catch((err)=>{}).then(()=>{dialogs.queue=null})}
if(showPreviews&&intermediateImage!==null){ // todo check if channel is nsfw, hide if not
var previewImg=intermediateImage
if(previewImg!==null){statusObj.file={file:previewImg,contentType:'image/png',name:next.id+'.png'}}
} else { // previewImages disabled, show spinner instead
//var spinnergif='https://cdn.discordapp.com/attachments/968822563662860338/1117650395414667274/animation_200_lis9s2g5.gif'
//statusObj.content+='\n'+spinnergif
//statusObj.file={file:spinner,contentType:'image/gif',name:spinnerFilename}
}
dialogs.queue.edit(statusObj)
.then(x=>{
dialogs.queue=x
sent=true
queueStatusLock=false
})
.catch((err)=>{queueStatusLock=false;sent=false})
} else { // new progress dialog
if(sent===false&&dialogs.queue===null){
bot.createMessage(chan,statusMsg).then(x=>{dialogs.queue=x;queueStatusLock=false}).catch((err)=>{dialogs.queue=null;queueStatusLock=false})
}
}
}
}
function closestRes(n){ // diffusion needs a resolution as a multiple of 64 pixels, find the closest
var q, n1, n2; var m=64
q=n/m
n1=m*q
if((n*m)>0){n2=m*(q+1)}else{n2=m*(q-1)}
if(Math.abs(n-n1)<Math.abs(n-n2)){return n1.toFixed(0)}
return n2.toFixed(0)
}
function prepSlashCmd(options) { // Turn partial options into full command for slash commands, hate the redundant code here
var job={}
var defaults=[{ name: 'prompt', value: ''},{name: 'width', value: defaultSize},{name:'height',value:defaultSize},{name:'steps',value:defaultSteps},{name:'scale',value:defaultScale},{name:'sampler',value:defaultSampler},{name:'seed', value: getRandomSeed()},{name:'strength',value:0.75},{name:'number',value:1},{name:'gfpgan_strength',value:0},{name:'codeformer_strength',value:0},{name:'upscale_strength',value:0.5},{name:'upscale_level',value:''},{name:'seamless',value:false},{name:'variation_amount',value:0},{name:'with_variations',value:[]},{name:'threshold',value:0},{name:'perlin',value:0},{name:'hires_fix',value:false},{name:'model',value:defaultModel}]
defaults.forEach(d=>{if(options.find(o=>{if(o.name===d.name){return true}else{return false}})){job[d.name]=options.find(o=>{if(o.name===d.name){return true}else{return false}}).value}else{job[d.name]=d.value}})
return job
}
function getCmd(newJob){
var cmd = newJob.prompt+' --width ' + newJob.width + ' --height ' + newJob.height + ' --seed ' + newJob.seed + ' --scale ' + newJob.scale + ' --sampler ' + newJob.sampler + ' --steps ' + newJob.steps + ' --strength ' + newJob.strength + ' --n ' + newJob.number + ' --gfpgan_strength ' + newJob.gfpgan_strength + ' --codeformer_strength ' + newJob.codeformer_strength + ' --upscale_level ' + newJob.upscale_level + ' --upscale_strength ' + newJob.upscale_strength + ' --threshold ' + newJob.threshold + ' --perlin ' + newJob.perlin + ' --seamless ' + newJob.seamless + ' --hires_fix ' + newJob.hires_fix + ' --variation_amount ' + newJob.variation_amount + ' --with_variations ' + newJob.with_variations + ' --model ' + newJob.model
if(newJob.text_mask){cmd+=' --text_mask '+newJob.text_mask}
return cmd
}
function getRandomSeed(){return Math.floor(Math.random()*4294967295)}
async function chat(msg){if(msg!==null&&msg!==''){bot.createMessage(config.channelID, msg).then().catch(err=>{log(err)})}}
async function chatChan(channel,msg){if(msg!==null&&msg!==''){bot.createMessage(channel, msg).then().catch(err=>{log(err)})}}
function sanitize(prompt){
if(prompt===undefined)return
if(config.bannedWords.length>0){config.bannedWords.split(',').forEach((bannedWord,index)=>{var regex = new RegExp(bannedWord, 'gi');prompt=prompt.replaceAll(regex,'')})}
return prompt.replaceAll(/[^一-龠ぁ-ゔァ-ヴーa-zA-Z0-9_a-zA-Z0-9々〆〤ヶ+()=!\"\&\*\[\]<>\\\/\- ,.\:\u0023-\u0039\u200D\u20E3\u2194-\u2199\u21A9-\u21AA\u231A-\u231B\u23E9-\u23EC\u23F0\u23F3\u25AA-\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614-\u2615\u261D\u263A\u2648-\u2653\u2660\u2663\u2665-\u2666\u2668\u267B\u267F\u2693\u26A0-\u26A1\u26AA-\u26AB\u26BD-\u26BE\u26C4-\u26C5\u26CE\u26D1\u26D3-\u26D4\u26E9\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2733-\u2734\u2744\u2747\u274C-\u274D\u274E\u2753-\u2755\u2757\u2763-\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934-\u2935\u2B05-\u2B07\u2B1B-\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299àáâãäåçèéêëìíîïñòóôõöøùúûüýÿ\u00A0\uFEFF]/g, '').replaceAll('`','')
}
function authorised(who,channel,guild) {
if (userid===config.adminID){return true} // always allow admin
var bannedUsers=[];var allowedGuilds=[];var bannedGuilds=[]; var allowedChannels=[];var ignoredChannels=[];var userid=null;var username=null
if (who.user && who.user.id && who.user.username){userid = who.user.id;username = who.user.username} else {userid=who.author.id;username=who.author.username}
if (config.bannedUsers?.length>0){bannedUsers=config.bannedUsers.split(',')}
if (config.allowedGuilds?.length>0){allowedGuilds=config.allowedGuilds.split(',')}
if (config.bannedGuilds&&config.bannedGuilds.length>0){bannedGuilds=config.bannedGuilds.split(',')}
if (config.allowedChannels?.length>0){allowedChannels=config.allowedChannels.split(',')}
if (config.ignoredChannels?.length>0){ignoredChannels=config.ignoredChannels.split(',')}
if (bannedUsers.includes(userid)){
log('auth fail, user is banned:'+username);return false
} else if(guild && allowedGuilds.length>0 && !allowedGuilds.includes(guild)||guild&&bannedGuilds.includes(guild)){
log('auth fail by '+username+', guild not allowed:'+guild);return false
} else if(channel && allowedChannels.length>0 && !allowedChannels.includes(channel)){
log('auth fail by '+username+', channel not allowed:'+channel);return false
} else if (channel && ignoredChannels.length>0 && ignoredChannels.includes(channel)){
log('auth fail by '+username+', channel is ignored:'+channel);return false
} else { return true }
}
function createNewUser(id){
if(id.id){id=id.id}
users.push({id:id, credits:100}) // 100 creds for new users
dbWrite() // Sync after new user
log('created new user with id '.bgBlue.black.bold + id)
}
function userCreditCheck(userID,amount) { // Check if a user can afford a specific amount of credits, create if not existing yet
var user=users.find(x=>x.id===String(userID))
if(!user){createNewUser(userID);user=users.find(x=>x.id===String(userID))}
if(parseFloat(user.credits)>=parseFloat(amount)||creditsDisabled){return true}else{return false}
}
function costCalculator(job) { // Pass in a render, get a cost in credits
if(creditsDisabled){return 0} // Bypass if credits system is disabled
var cost=1 // a normal render base cost, 512x512 30 steps
var pixelBase=defaultSize*defaultSize // reference pixel size
var pixels=job.width*job.height // How many pixels does this render use?
cost=(pixels/pixelBase)*cost // premium or discount for resolution relative to default
cost=(job.steps/defaultSteps)*cost // premium or discount for step count relative to default
if (job.gfpgan_strength!==0){cost=cost*1.05} // 5% charge for gfpgan face fixing (minor increased processing time)
if (job.codeformer_strength!==0){cost=cost*1.05} // 5% charge for gfpgan face fixing (minor increased processing time)
if (job.upscale_level===2){cost=cost*1.5} // 1.5x charge for upscale 2x (increased processing+storage+bandwidth)
if (job.upscale_level===4){cost=cost*2} // 2x charge for upscale 4x
if (job.hires_fix===true){cost=cost*1.5} // 1.5x charge for hires_fix (renders once at half resolution, then again at full)
//if (job.channel!==config.channelID){cost=cost*1.1}// 10% charge for renders outside of home channel
cost=cost*job.number // Multiply by image count
return cost.toFixed(2) // Return cost to 2 decimal places
}
function creditsRemaining(userID){return users.find(x=>x.id===userID).credits}
function chargeCredits(userID,amount){
if(!creditsDisabled){
var user=users.find(x=>x.id===userID)
if (user){
user.credits=(user.credits-amount).toFixed(2)
dbWrite()
var z='charged id '+userID+' - '+amount.toFixed(2)+'/'
if(user.credits>90){z+=user.credits.bgBrightGreen.black}else if(user.credits>50){z+=user.credits.bgGreen.black}else if(user.credits>10){z+=user.credits.bgBlack.white}else{z+=user.credits.bgRed.white}
log(z.dim.bold)
} else {
log('Unable to find user: '+userID)
}
}
}
async function creditTransfer(credits,from,to,channel){ // allow credit transfers between users
var userFrom=users.find(x=>x.id===from)
var userTo=users.find(x=>x.id===to)
if(!userTo){createNewUser(to);userTo=users.find(x=>x.id===to)}
if(parseFloat(credits)&&userFrom&&userTo&&parseFloat(userFrom.credits)>(parseFloat(credits)+100)){ // Only allow users to transfer credits above and beyond the starter balance (only paid credit)
userFrom.credits=parseFloat(userFrom.credits)-parseFloat(credits) // todo charge user function rather then directly subtract from db
creditRecharge(credits,'transfer',userTo.id,credits+' CREDITS',userFrom.id) // add user credit, log in payment db
//userTo.credits=parseFloat(userTo.credits)+parseFloat(credits)
//payments.push({credits:credits,txid:'transfer',timestamp:moment.now(),userid:userTo,userFrom:userFrom,amount:credits+' CREDITS'})
//dbWrite() // save db after transfers
var successMsg='<@'+from+'> gifted `'+credits+'` :coin: to <@'+to+'>'
log(successMsg)
try{bot.createMessage(channel,successMsg)}catch(err){log(err)}
} else if (parseFloat(credits)>parseFloat(userFrom.credits)) {
var errorMsg=':warning: <@'+from+'> has insufficient balance to gift `'+credits+'` :coin:'
try{bot.createMessage(channel,errorMsg)}catch(err){log(err)}
} else if (parseFloat(userFrom.credits)>(parseFloat(credits)+100)) {
var errorMsg=':warning: Only paid credit beyond the initial 100 free :coin: can be transferred'
try{bot.createMessage(channel,errorMsg)}catch(err){log(err)}
} else {
log('Gifting failed for '+from)
}
}
async function creditRecharge(credits,txid,userid,amount,from=false){ // add credit to user
var user=users.find(x=>x.id===String(userid))
if(!user){await createNewUser(userid);var user=users.find(x=>x.id===userid)}
if(user&&user.credits){user.credits=(parseFloat(user.credits)+parseFloat(credits)).toFixed(2)}
if(!['manual','free','transfer'].includes(txid)){
payments.push({credits:credits,timestamp:moment.now(),txid:txid,userid:userid,amount:amount,})
var paymentMessage = ':tada: <@'+userid+'> added :coin:`'+credits+'`, balance is now :coin:`'+user.credits+'`\n:heart_on_fire:'
if(from){paymentMessage+='Thanks `'+from+'` for the `'+amount+'` donation to the GPU fund.\n Type !recharge to get your own topup info'
}else{paymentMessage+='Thanks for the `'+amount+'` donation to the GPU fund.\n Type !recharge to get your own topup info'}
chat(paymentMessage)
directMessageUser(config.adminID,paymentMessage)
}
dbWrite()
}
async function freeRecharge(){
// allow for regular topups of empty accounts
// new users get 100 credits on first appearance, then freeRechargeAmount more every 12 hours IF their balance is less then freeRechargeMinBalance
// extra points given if the user voted the bot on top.gg within the last 12 hours
var freeRechargeMinBalance=parseInt(config.freeRechargeMinBalance)||10
var freeRechargeAmount=parseInt(config.freeRechargeAmount)||10
var freeRechargeUsers=users.filter(u=>u.credits<freeRechargeMinBalance)
var voters=[]
if(freeRechargeUsers.length>0&&freeRechargeAmount>0){
log(freeRechargeUsers.length+' users with balances below '+freeRechargeMinBalance+' getting a free '+freeRechargeAmount+' credit topup')
if(config.topggKey&&config.topggKey.length>0){voters=await topGGVoterIds().then().catch(e=>log(e))} // Get list of users that voted this month
freeRechargeUsers.forEach(async u=>{
var voteIncentive=0
if(voters.includes(u.id)){
voteIncentive=5 // +5 for voting this month
var votedRecently=await topGGHasVoted(u)
if(votedRecently) voteIncentive=10 // +10 for voting in the last 12 hours
}
var nc = parseFloat(u.credits)+freeRechargeAmount+voteIncentive // Incentivizes drain down to 9 for max free charge leaving balance at 19 + vote bonus for max balance of 29
creditRecharge(nc,'free',u.id,nc+' CREDITS',config.adminID)
directMessageUser(u.id,':fireworks: You received a free '+(freeRechargeAmount+voteIncentive)+' :coin: topup!\n:information_source:Everyone with a balance below '+freeRechargeMinBalance+' will get this once every 12 hours')
})
chat(':fireworks:'+freeRechargeUsers.length+' users with a balance below `'+freeRechargeMinBalance+'`:coin: just received their free credit recharge')
}else{log('No users eligible for free credit recharge')}
}
async function dbWrite() {
time('dbWrite')
const files = [{name:'./config/dbQueue.json',data:{queue:queue}},{name:'./config/dbUsers.json',data:{users:users}},{name:'./config/dbPayments.json',data:{payments:payments}}]
files.forEach(async (file) => {
const dataExists = await fsExists(file.name)
var dataOnDisk = await fsPromises.readFile(file.name,'utf8')
const dataIsDifferent = dataExists && JSON.stringify(file.data) !== dataOnDisk
if(dataIsDifferent){
try{
await fsPromises.writeFile(`${file.name}.tmp.json`, JSON.stringify(file.data))
await fsPromises.rename(`${file.name}.tmp.json`, file.name)
}catch(err){log('Failed to write db file'.bgRed);log(err)}
}
})
timeEnd('dbWrite')
}
dbWrite=debounce(dbWrite,10000,true) // at least 10 seconds between writes
function dbRead() { // todo update to async fsPromises.readFile
time('dbRead')
try{
queue=JSON.parse(fs.readFileSync('./config/dbQueue.json')).queue
users=JSON.parse(fs.readFileSync('./config/dbUsers.json')).users
payments=JSON.parse(fs.readFileSync('./config/dbPayments.json')).payments
} catch (err){log('Failed to read db files'.bgRed);log(err)}
timeEnd('dbRead')
}
async function dbScheduleRead(){
log('read schedule db'.grey.dim)
try{
await fsPromises.readFile(dbScheduleFile)
.then(data=>{
var j=JSON.parse(data)
schedule=j.schedule
scheduleInit()
})
.catch(err=>{log(err)})
}
catch(err){console.error('failed to read schedule db');console.error(err)}
}
function scheduleInit(){
// cycle through the active schedule jobs, set up render jobs with cron
log('init schedule'.grey)
schedule.filter(s=>s.enabled==='True').forEach(s=>{
log('Scheduling job: '.grey+s.name)
cron.schedule(s.cron,()=>{
log('Running scheduled job: '.grey+s.name)
var randomPromptObj=s.prompts[Math.floor(Math.random()*s.prompts.length)]
var randomPrompt = randomPromptObj.prompt
Object.keys(randomPromptObj).forEach(key => {
if(key!=='prompt'){
randomPrompt += ` --${key} ${randomPromptObj[key]}`
}
});
var newRequest={cmd: randomPrompt, userid: s.admins[0].id, username: s.admins[0].username, bot: 'False', channelid: s.channel, attachments: []}
if(s.onlyOnIdle==="True"){if(queue.filter((q)=>q.status==='new').length>0){log('Ignoring scheduled job due to renders')}else{request(newRequest)}}else{request(newRequest)}
})
})
}
async function getPrices () { // todo include hive engine token prices if enabled
return new Promise(async (resolve,reject)=>{
time('getPrices')
var url='https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=hive&order=market_cap_asc&per_page=1&page=1&sparkline=false'
await axios.get(url)
.then((response)=>{hiveUsd=response.data[0].current_price;lastHiveUsd=hiveUsd;log('HIVE: $'+hiveUsd);resolve(true)})
.catch(async()=>{
log('Failed to load data from coingecko api, trying internal market'.red.bold)
await axios.post(hiveEndpoints[0], {id: 1,jsonrpc: '2.0',method: 'condenser_api.get_ticker',params: []})
.then((hresponse)=>{hiveUsd=parseFloat(hresponse.data.result.latest);log('HIVE (internal market): $'+hiveUsd);resolve(true)})
.catch(async(err)=>{
log('Failed to load data from hive market api')
log(err)
await axios('https://api.ausbit.dev/price') // last resort is my own api node
.then((r=>{
hiveUsd=parseFloat(r.data.hive.usd)
log('got hive price from api.ausbit.dev/price : '+hiveUsd)
resolve(true)
}))
.catch((err)=>{
log('all price discovery methods failed, falling back to previous hive price of '+lastHiveUsd)
hiveUsd=lastHiveUsd
reject(err)
})
})
})
timeEnd('getPrices')
})
}
function getLightningInvoiceQr(memo,amount=1){
var appname=config.hivePaymentAddress+'_discord'
return 'https://api.v4v.app/v1/new_invoice_hive?hive_accname='+config.hivePaymentAddress+'&amount='+amount+'¤cy=HBD&usd_hbd=false&app_name='+appname+'&expiry=300&message='+memo+'&qr_code=png'
}
function getPixelSteps(job){ // raw (width * height) * (steps * number). Does not account for postprocessing
var p=parseInt(job.width)*parseInt(job.height)
var s= parseInt(job.steps)*parseInt(job.number)
var ps=p*s
return ps
}
function getPixelStepsTotal(jobArray){
var ps=0
jobArray.forEach((j)=>{ps=ps+getPixelSteps(j)})
return ps
}
function balancePrompt(userid,channel){
userCreditCheck(userid,1) // make sure the account exists first
var msg='<@'+userid+'> you have `'+creditsRemaining(userid)+'` :coin:'
log('Balance Check: '+msg)
bot.createMessage(channel,msg).then().catch(err=>{log(err)})
}
async function rechargePrompt(userid,channel){
userCreditCheck(userid,1) // make sure the account exists first
checkNewPayments()
var user=users.find(x=>x.id===String(userid))
var hive1usd = (1/hiveUsd).toFixed(3)
var paymentMemo=config.hivePaymentPrefix+userid
var paymentLinkHbd='https://hivesigner.com/sign/transfer?to='+config.hivePaymentAddress+'&amount=1.000%20HBD&memo='+paymentMemo
var paymentLinkHive='https://hivesigner.com/sign/transfer?to='+config.hivePaymentAddress+'&amount='+hive1usd+'%20HIVE&memo='+paymentMemo
var lightningInvoiceQr=getLightningInvoiceQr(paymentMemo)
var paymentMsg=''
paymentMsg+='<@'+userid+'> you have `'+creditsRemaining(userid)+'` :coin: remaining\n*The rate is `1` **USD** per `500` :coin: *\n'
paymentMsg+= 'You can send any amount of **HIVE** <:hive:1110123056501887007> or **HBD** <:hbd:1110282940686016643> to `'+config.hivePaymentAddress+'` with the memo `'+paymentMemo+'` to top up your balance\n'
if(config.allowHiveEnginePayments&&config.allowedHETokens.length>0){
// hive engine transfers
// var transferjson=
// https://hivesigner.com/sign/custom-json?authority=active&required_auths=%5B%22'+username+'%22%5D&required_posting_auths=%5B%5D&id=ssc-mnainnet-hive&json=${encodeURIComponent(json)}&redirect_uri=https://ecency.com/
paymentMsg+='\n**Accepting these Hive-Engine tokens:** '+config.allowedHETokens
}
//paymentMsg+= '**Pay 1 HBD:** '+paymentLinkHbd+'\n**Pay 1 HIVE:** '+paymentLinkHive
var freeRechargeMsg='..Or just wait for your FREE recharge of 10 credits twice daily'
var rechargeImages=['https://media.discordapp.net/attachments/1024766656347652186/1110852592864595988/237568213750251520-1684918295766-text.png','https://media.discordapp.net/attachments/1024766656347652186/1110862401420677231/237568213750251520-1684920634773-text.png','https://media.discordapp.net/attachments/1024766656347652186/1110865969645105213/237568213750251520-1684921485321-text.png','https://media.discordapp.net/attachments/968822563662860338/1110869028475523174/237568213750251520-1684922214077-text.png','https://media.discordapp.net/attachments/1024766656347652186/1110872463736324106/237568213750251520-1684923032433-text.png','https://media.discordapp.net/attachments/1024766656347652186/1110875096106676256/237568213750251520-1684923660927-text.png','https://media.discordapp.net/attachments/1024766656347652186/1110876051694952498/237568213750251520-1684923889116-text.png','https://media.discordapp.net/attachments/1024766656347652186/1110877696726159370/237568213750251520-1684924281507-text.png','https://media.discordapp.net/attachments/968822563662860338/1110904225384382554/merged_canvas.da2c2db8.png','https://media.discordapp.net/attachments/968822563662860338/1123814289157935154/237568213750251520-1688008605035-text.png','https://media.discordapp.net/attachments/326767097629769741/1123828583979302932/237568213750251520-1688011997096-text.png']
shuffle(rechargeImages)
var paymentMsgObject={
content: paymentMsg,
embeds:
[
{image:{url:rechargeImages[0]}},
{footer:{text:freeRechargeMsg}}
],
components: [
{type: 1, components:[
{type: 2, style: 5, label: hive1usd+" HIVE", url:paymentLinkHive, emoji: { name: 'hive', id: '1110123056501887007'}, disabled: false },
{type: 2, style: 5, label: "1 HBD", url:paymentLinkHbd, emoji: { name: 'hbd', id: '1110282940686016643'}, disabled: false },
{type: 2, style: 5, label: "$1 of BTC", url:lightningInvoiceQr, emoji: { name: '⚡', id: null}, disabled: false }
]}
]
}
if(config.allowStripePayments&&config.stripeKey.length>0){
var paymentLinkStripePrefix='http://buy.stripe.com/'
if(user.stripe&&user.stripe.length>0){
var paymentLinkStripe=paymentLinkStripePrefix+user.stripe
} else {
var paymentLinkStripe=await generateStripeLink(userid)
var paymentLinkStripeSuffix=paymentLinkStripe.split('/')[3]
if(paymentLinkStripeSuffix.length>0){
debugLog('saving new stripe payment code '+paymentLinkStripeSuffix+' for user '+userid)
user.stripe=paymentLinkStripe.split('/')[3]
dbWrite()
}
}
paymentMsgObject.components[0].components.push({type: 2, style: 5, label: "CC via Stripe", url:paymentLinkStripe, emoji: { name: '💳', id: null}, disabled: false })
}
directMessageUser(userid,paymentMsgObject,channel).catch((err)=>log(err))
log('ID '+userid+' asked for recharge link')
}
rechargePrompt=debounce(rechargePrompt,1000,true) // at least 1 second between checks
async function checkNewPayments(){
time('checkNewPayments')
// Hive native payments support
var bitmask=['4','524288'] // transfers and fill_recurrent_transfer only
var accHistoryLength=config.accHistoryLength||100 // default 100
log('Checking recent payments for '.grey+config.hivePaymentAddress.grey)
hive.api.getAccountHistory(config.hivePaymentAddress, -1, accHistoryLength, ...bitmask, function(err, result) {
if(err){log(err)}
if(Array.isArray(result)) {
result.forEach(r=>{
var tx=r[1]
var txType=tx.op[0]
var op=tx.op[1]
if(txType==='transfer'&&op.to===config.hivePaymentAddress&&op.memo.startsWith(config.hivePaymentPrefix)){
var amountCredit=0
var accountId=op.memo.replace(config.hivePaymentPrefix,'')
var pastPayment=payments.find(x=>x.txid===tx.trx_id)
if(pastPayment===undefined){
coin=op.amount.split(' ')[1]
amount=parseFloat(op.amount.split(' ')[0])
if(coin==='HBD'){amountCredit=amount*500}else if(coin==='HIVE'){amountCredit=(amount*hiveUsd)*500}
log('New Payment! credits:'.bgBrightGreen.red+amountCredit+' , amount:'+op.amount)
creditRecharge(amountCredit,tx.trx_id,accountId,op.amount,op.from)
}
}
})
} else {log('error fetching account history'.bgRed)}
})
// Hive-Engine payments support
if(config.allowHiveEnginePayments){
var allowedHETokens=config.allowedHETokens?.split(',')||['SWAP.HBD','SWAP.HIVE']
try{response = await axios.get('https://history.hive-engine.com/accountHistory?account='+config.hivePaymentAddress+'&limit='+accHistoryLength+'&offset=0&ops=tokens_transfer')}catch(err){log(err)}
var HEtransactions=response?.data
var HEbotPayments=HEtransactions.filter(t=>t.to===config.hivePaymentAddress&&allowedHETokens.includes(t.symbol)&&t.memo?.startsWith(config.hivePaymentPrefix))
var HEapi='https://he.ausbit.dev/contracts'
var HEapiData={"jsonrpc":"2.0","id":17,"method":"find","params":{"contract":"market","table":"metrics","query":{"symbol":{"$in":allowedHETokens}},"limit":1000,"offset":0,"indexes":[]}}
var heTokenPriceCache=null
await axios.post(HEapi,HEapiData).then(r=>{heTokenPriceCache=r.data.result}).catch(err=>{log(err)})
HEbotPayments.forEach(t=>{
var pastPayment=payments.find(tx=>tx.txid===t.transactionId)
var usdValue=0
var userid=parseInt(t.memo.split('-')[1])
if(pastPayment===undefined&&Number.isInteger(userid)){ // not in db yet, memo looks correct
log('potential hive engine recharge..')
switch(t.symbol){
case 'SWAP.HIVE': usdValue=parseFloat(t.quantity)*(hiveUsd*0.9925);break // treat like regular HIVE - 0.75% HE withdraw fee
case 'SWAP.HBD': usdValue=parseFloat(t.quantity)*0.9925;break // treat like regular HBD and peg to $1 - 0.75% HE withdraw fee
default: {
try{
var priceObj=heTokenPriceCache.find(p=>p.symbol===t.symbol)
var price=parseFloat(priceObj.lastPrice)*hiveUsd
usdValue=parseFloat(t.quantity)*price*0.9925
debugLog('calculated price for '+t.quantity+' '+t.symbol+' : '+usdValue)
}catch(err){log(err)}
break
}
}
if(allowedHETokens.includes(t.symbol)&&usdValue>0.0001){ // min 0.001 usd recharge
debugLog('calculated price for '+t.quantity+' '+t.symbol+' : '+usdValue)
var credits=usdValue*500
log('New Payment! credits:'.bgBrightGreen.red+credits+' , amount:'+t.quantity+' '+t.symbol+' , from: '+t.from)
creditRecharge(credits,t.transactionId,String(userid),t.quantity+' '+t.symbol,t.from)
}
}
})
}
// Stripe payments support
if(config.allowStripePayments&&config.stripeKey){
getStripePayments()
}
timeEnd('checkNewPayments')
}
checkNewPayments=debounce(checkNewPayments,30000,true) // at least 30 seconds between checks
const sendWebhook=(job)=>{ // TODO eris has its own internal webhook method, investigate and maybe replace this
let embeds=[{color:getRandomColorDec(),footer:{text:job.prompt},image:{url:job.webhook.imgurl}}]
axios({method:"POST",url:job.webhook.url,headers:{ "Content-Type": "application/json" },data:JSON.stringify({embeds})})
.then((response) => {log("Webhook delivered successfully")})
.catch((error) => {console.error(error)})
}
function requestModelChange(newmodel){log('Requesting model change to '+newmodel);if(newmodel===undefined||newmodel==='undefined'){newmodel=defaultModel}socket.emit('requestModelChange',newmodel,()=>{log(newmodel+' loaded')})}
function generationResult(data){
time('generationResult')
var url=data.url
url=config.basePath+data.url.split('/')[data.url.split('/').length-1]
var job=queue[queue.findIndex(j=>j.status==='rendering')] // TODO there has to be a better way to know if this is a job from the web interface or the discord bot // upcoming invokeai api release solves this
if(job){
var postRenderObject={id:job.id,filename: url, seed: data.metadata.image.seed, resultNumber:job.results.length, width:data.metadata.image.width,height:data.metadata.image.height}
// remove redundant data before pushing to db results
delete (data.metadata.prompt);delete (data.metadata.seed);delete (data.metadata.model_list);delete (data.metadata.app_id);delete (data.metadata.app_version); delete (data.attentionMaps);
job.results.push(data)
postRender(postRenderObject)
}else{rendering=false}
if(job&&job.results.length>=job.number){job.status='done';dbWrite();rendering=false;processQueue()}// else {debugLog('Not marking job done, waiting for more images')}
if(dialogs.queue!==null){dialogs.queue.delete().catch((err)=>{}).then(()=>{dialogs.queue=null;intermediateImage=null})}
timeEnd('generationResult')
}
async function postRender(render){
time('postRender')
var filenamesplit=render.filename.split('/')
debugLog(config.apiUrl+filenamesplit[filenamesplit.length-1]) // can access image via this url internally
await fsPromises.readFile(render.filename, null) //updated to async version
.then(async data=>{
// NOTE: filename being wrong wasn't breaking because slashes get replaced automatically in createMessage, but makes filename long/ugly
filename=render.filename.split('\\')[render.filename.split('\\').length-1].replace(".png","")
//var job=queue[queue.findIndex(x=>x.id===render.id)]
var job=idToJob(render.id)
var msg=':brain:<@'+job.userid+'>'
if (showRenderSettings){
msg+=':straight_ruler:`'+render.width+'x'+render.height+'`'
if(job.upscale_level!==''){msg+=':mag:**`Upscaledx'+job.upscale_level+' to '+(parseFloat(job.width)*parseFloat(job.upscale_level))+'x'+(parseFloat(job.height)*parseFloat(job.upscale_level))+' ('+job.upscale_strength+')`**'}
if(job.gfpgan_strength!==0){msg+=':magic_wand:`gfpgan face fix('+job.gfpgan_strength+')`'}
if(job.codeformer_strength!==0){msg+=':magic_wand:`codeformer face fix(' + job.codeformer_strength + ')`'}
if(job.seamless===true){msg+=':knot:**`Seamless Tiling`**'}
if(job.hires_fix===true){msg+=':telescope:**`High Resolution Fix ('+job.strength+')`**'}
if(job.perlin!==0){msg+=':oyster:**`Perlin '+job.perlin+'`**'}
if(job.threshold!==0){msg+=':door:**`Threshold '+job.threshold+'`**'}
if(job.attachments.length>0){msg+=':paperclip:` attached template`:muscle:`'+job.strength+'`'}
if(job.text_mask){msg+=':mask:`'+job.text_mask+'`'}
if(job.variation_amount!==0){msg+=':microbe:**`Variation '+job.variation_amount+'`**'}
if(job.symv||job.symh){msg+=':mirror: `v'+job.symv+',h'+job.symh+'`'}
if(render.variations){msg+=':linked_paperclips:with variants `'+render.variations+'`'}
// Added spaces to make it easier to double click the seed to copy/paste, otherwise discord selects whole line
msg+=':seedling: `'+render.seed+'` :scales:`'+job.scale+'`:recycle:`'+job.steps+'`'
msg+=':stopwatch:`'+timeDiff(job.timestampRequested, moment())+'s`'
//if(showFilename){msg+=':file_cabinet:`'+filename+'`'}
msg+=':eye:`'+job.sampler+'`'
msg+=':floppy_disk:`'+job.model+'`'
}
if(job.webhook){msg+='\n:calendar:Scheduled render sent to `'+job.webhook.destination+'` discord'}
if(job.cost&&!creditsDisabled){
chargeCredits(job.userid,(costCalculator(job))/job.number) // only charge successful renders, if enabled
msg+=':coin:`'+(job.cost/job.number).toFixed(2).replace(/[.,]00$/, "")+'/'+ creditsRemaining(job.userid) +'`'
}
var postFilename=filename+'.png'
var nsfwWarning = job.censoredPrompt ? ':warning: **NSFW prompt modified for SFW channel**\n' : ''
var channel = job.channel ? await bot.getChannel(job.channel) : undefined
var channelNsfw=false;var imageNsfw=false // Is this a NSFW channel ?
if((channel&&Object.keys(channel).includes('nsfw'))){channelNsfw=channel.nsfw}
if(job.guild===undefined){channelNsfw=true} // disable in DM
if(channelNsfw===undefined) channelNsfw=false
if(!config.nsfwChecksEnabled){channelNsfw=true} // bypass if disabled in config
if(!channelNsfw){ // If in SFW channel,
imageNsfw = await isImageNSFW(data) // scan result with nsfwjs
if(imageNsfw){
log('spoilered nsfw image')
postFilename='SPOILER_'+postFilename
nsfwWarning+=':see_no_evil: **Possible NFSW image detected in SFW channel**\n'
}
}
var newMessage = { content: msg, embeds: [{description: nsfwWarning+job.prompt, color: getRandomColorDec()}], components: [ { type: 1, components: [ ] } ] }
if(job.prompt.replaceAll(' ','').length===0){newMessage.embeds=[]}
newMessage.components[0].components.push({ type: 2, style: 2, label: "ReDream", custom_id: "refresh-" + job.id, emoji: { name: '🎲', id: null}, disabled: false })
newMessage.components[0].components.push({ type: 2, style: 2, label: "Edit Prompt", custom_id: "edit-"+job.id, emoji: { name: '✏️', id: null}, disabled: false })
newMessage.components[0].components.push({ type: 2, style: 2, label: "Tweak", custom_id: "tweak-"+job.id+'-'+render.resultNumber, emoji: { name: '🧪', id: null}, disabled: false })
if(newMessage.components[0].components.length<5){newMessage.components[0].components.push({ type: 2, style: 2, label: "Random", custom_id: "editRandom-"+job.id, emoji: { name: '🔀', id: null}, disabled: false })}
if(newMessage.components[0].components.length===0){delete newMessage.components} // If no components are used there will be a discord api error so remove it
var filestats=await fsPromises.stat(render.filename)
var filesize=filestats.size
if(filesize<defaultMaxDiscordFileSize){ // Within discord 25mb filesize limit
try{
bot.createMessage(job.channel, newMessage, {file: data, name: postFilename})
.then(async m=>{
debugLog('Posted msg id '+m.id+' to channel id '+m.channel.id)
if(m.attachments.length>0){
log('Prompt: '+job.prompt)
log('Url: '+m.attachments[0].proxy_url.blue.underline)
}
//debugLog(render.filename+' - '+(filesize/1000000).toFixed(2)+'mb')
// todo conditional archiving to a folder named after discord id for long term storage
// just admin / just paid users / everyone / role based ?
//if(archiveAfterPost){
// make directory if not exist archiveBase+'/'+job.userid
// var newfilename=archiveBase+'/'+job.userid
// await fsPromises.writeFile(newfilename,data)
//}
if(deleteAfterPost){
// not currently removing outputs\thumbnails (256x256 .webp's)
fsPromises.unlink(render.filename)
.then(debugLog('deleted '+render.filename))
.catch(err=>log(err))
}
}) // maybe wait till successful post here to change job status? Could store msg id to job
.catch((err)=>{
log('caught error posting to discord in channel '.bgRed+job.channel)
log(err)
failedToPost.push({channel:job.channel,msg:newMessage,file:data,name:filename+'.png'})
})
}catch(err){console.error(err)}
}else{
log('Image '+filename+' was too big for discord, failed to post to channel '+job.channel)
failedToPost.push({channel:job.channel,msg:newMessage,file:data,name:filename+'.png'})
}