forked from ausbitbank/stable-diffusion-discord-bot
-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.js
2264 lines (2237 loc) · 148 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 config = 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 fs = require('fs')
const path = require('path') // still needed?
const axios = require('axios')
var parseArgs = require('minimist')
const chokidar = require('chokidar')
const moment = require('moment')
// const { ImgurClient } = require('imgur')
// const imgur = new ImgurClient({ clientId: config.imgurClientID})
// const imgbb = require("imgbb-uploader")
// const { Rembg } = require("rembg-node")
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)}}
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')
var paused=false // unused?
var queue = []
var users = []
var payments = []
dbRead()
// Setup scheduler, start repeating checks
var schedule = []
var dbScheduleFile='./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')
const { exit } = require('process')
if(config.creditsDisabled==='true'){var creditsDisabled=true}else{var creditsDisabled=false}
if(config.showFilename==='true'){var showFilename=true}else{var showFilename=false}
if(config.hivePaymentAddress.length>0 && !creditsDisabled){
hive.config.set('alternative_api_endpoints',['https://rpc.ausbit.dev','https://api.hive.blog','https://api.deathwing.me','https://api.c0ff33a.uk','https://hived.emre.sh'])
var hiveUsd = 0.4
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() })
cron.schedule('0 */12 * * *', () => { log('Recharging users with no credit every 12 hrs'.bgCyan.bold); freeRecharge() }) // Comment this out if you don't want free regular topups of low balance users
}
const bot = new Eris.CommandClient(config.discordBotKey, {
intents: ["guilds", "guildMessages", "messageContent", "directMessages", "guildMessageReactions", "directMessageReactions"],
description: "Just a slave to the art, maaan",
owner: "ausbitbank",
prefix: "!",
reconnect: 'auto',
compress: true,
getAllUsers: false, //drastically affects startup time if true
})
const defaultSize = parseInt(config.defaultSize)||512
const defaultSteps = parseInt(config.defaultSteps)||50
const defaultScale = parseFloat(config.defaultScale)||7.5
const defaultStrength = parseFloat(config.defaultStrength)||0.45
const maxSteps = parseInt(config.maxSteps)||100
const maxIterations = parseInt(config.maxIterations)||10
const defaultMaxDiscordFileSize=parseInt(config.defaultMaxDiscordFileSize)||25000000
const basePath = config.basePath
const maxAnimateImages = 100 // Only will fetch most recent X images for animating
const allGalleryChannels = fs.existsSync('dbGalleryChannels.json') ? JSON.parse(fs.readFileSync('dbGalleryChannels.json', 'utf8')) : {}
const allNSFWChannels = fs.existsSync('dbNSFWChannels.json') ? JSON.parse(fs.readFileSync('dbNSFWChannels.json', 'utf8')) : {}
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 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)
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{
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,discriminator:i.member?i.member.user.discriminator:i.user.discriminator,bot:i.member?i.member.user.bot:i.user.bot,channelid:i.channel.id,attachments:attachment})
/*if (i.member) {
request({cmd: getCmd(prepSlashCmd(i.data.options)), userid: i.member.id, username: i.member.user.username, discriminator: i.member.user.discriminator, bot: i.member.user.bot, channelid: i.channel.id, attachments: attachment})
} else if (i.user){
request({cmd: getCmd(prepSlashCmd(i.data.options)), userid: i.user.id, username: i.user.username, discriminator: i.user.discriminator, bot: i.user.bot, channelid: i.channel.id, 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,discriminator:i.member?i.member.user.discriminator:i.user.discriminator,bot:i.member?i.member.user.bot:i.user.bot,channelid:i.channel.id,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:[{name:'Arial',value:'Arial'},{name:'Comic Sans MS',value:'Comic Sans MS'},{name:'Tahoma',value:'Tahoma'},{name:'Times New Roman',value:'Times New Roman'},{name:'Verdana',value:'Verdana'},{name:'Lucida Console',value:'Lucida Console'}]},
{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='Arial',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)
}
},
]
// If credits are active, add /recharge otherwise don't include it
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)}}
})
}
// 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
})
}
function request(request){
// request = { cmd: string, userid: int, username: string, discriminator: int, bot: false, channelid: 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'],boolean: ['seamless','hires_fix']}) // parse arguments //
// messy code below contains defaults values, check numbers are actually numbers and within acceptable ranges etc
// let sanitize all the numbers first
debugLog(args)
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.renderer||['localApi'].includes(args.renderer)){args.renderer='localApi'}
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.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,
discriminator: request.discriminator,
timestampRequested: args.timestamp,
channel: request.channelid,
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,
renderer: args.renderer,
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
}
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)}
queue.push(newJob)
dbWrite() // Push db write after each new addition
processQueue()
// acknowledge received job with ethereal message here?
}
queueStatusLock=false
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+'**#'+next.discriminator+' :coin:`'+costCalculator(next)+'` :fire:`'+renderGps+'`'
var renderPercent=((parseInt(progressUpdate['currentStep'])/parseInt(progressUpdate['totalSteps']))*100).toFixed(2)
var renderPercentEmoji=':hourglass_flowing_sand:'
if(renderPercent>50){renderPercentEmoji=':hourglass:'}
statusMsg+='\n'+renderPercentEmoji+' `'+progressUpdate['currentStatus'].replace('common.status','')+'` '
if (progressUpdate['currentStatusHasSteps']===true){
statusMsg+='`'+renderPercent+'% Step '+progressUpdate['currentStep']+'/'+progressUpdate['totalSteps']+'`'
if (progressUpdate['totalIterations']>1){
statusMsg+=' Iteration `'+progressUpdate['currentIteration']+'/'+progressUpdate['totalIterations']+'`'
}
}
var statusObj={content:statusMsg}
if(next&&next.channel!=='webhook'){var chan=next.channel}else{var chan=config.channelID}
if(dialogs.queue!==null){
if(dialogs.queue.channel.id!==next.channel){dialogs.queue.delete().catch((err)=>{}).then(()=>{dialogs.queue=null})}
if(intermediateImage!==null){
var previewImg=intermediateImage
if(previewImg!==null){statusObj.file={file:previewImg,contentType:'image/png',name:next.id+'.png'}}
}
dialogs.queue.edit(statusObj)
.then(x=>{
dialogs.queue=x
sent=true
queueStatusLock=false
})
.catch((err)=>{queueStatusLock=false;sent=false})
}
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)}
function chat(msg){if(msg!==null&&msg!==''){try{bot.createMessage(config.channelID, msg)}catch(err){log(err)}}}
function chatChan(channel,msg){if(msg!==null&&msg!==''){try{bot.createMessage(channel, msg)}catch(err){log('Failed to send with error:'.bgRed);log(err)}}}
function sanitize(prompt){
if(config.bannedWords.length>0){config.bannedWords.split(',').forEach((bannedWord,index)=>{prompt=prompt.replaceAll(bannedWord,'')})}
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 base64Encode(file){var body=fs.readFileSync(file);return body.toString('base64')}
function authorised(who,channel,guild) {
if (userid===config.adminID){return true} // always allow admin
var bannedUsers=[];var allowedGuilds=[];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.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)){
log('auth fail, guild not allowed:'+guild);return false
} else if(channel && allowedChannels.length>0 && !allowedChannels.includes(channel)){
log('auth fail, channel not allowed:'+channel);return false
} else if (channel && ignoredChannels.length>0 && ignoredChannels.includes(channel)){
log('auth fail, 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 creditRecharge(credits,txid,userid,amount,from){
var user=users.find(x=>x.id===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(txid!=='manual'){
payments.push({credits:credits,txid:txid,userid:userid,amount:amount})
var paymentMessage = ':tada: <@'+userid+'> added :coin:`'+credits+'`, balance is now :coin:`'+user.credits+'`\n:heart_on_fire: Thanks `'+from+'` for the `'+amount+'` donation to the GPU fund.\n Type !recharge to get your own topup info'
chat(paymentMessage)
directMessageUser(config.adminID,paymentMessage)
}
dbWrite()
}
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
var freeRechargeMinBalance=parseInt(config.freeRechargeMinBalance)||10
var freeRechargeAmount=parseInt(config.freeRechargeAmount)||10
var freeRechargeUsers=users.filter(u=>u.credits<freeRechargeMinBalance)
if(freeRechargeUsers.length>0){
log(freeRechargeUsers.length+' users with balances below '+freeRechargeMinBalance+' getting a free '+freeRechargeAmount+' credit topup')
freeRechargeUsers.forEach(u=>{
u.credits = parseFloat(u.credits)+freeRechargeAmount // Incentivizes drain down to 9 for max free charge leaving balance at 19
// u.credits = 10 // Incentivizes completely emptying balance for max free charge leaving balance at 10
directMessageUser(u.id,':fireworks: You received a free '+freeRechargeAmount+' :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')
dbWrite()
}else{
log('No users eligible for free credit recharge')
}
}
/*function dbWrite(){
try{
fs.writeFileSync('dbQueue.json',JSON.stringify({queue:queue}))
fs.writeFileSync('dbUsers.json',JSON.stringify({users:users}))
fs.writeFileSync('dbPayments.json',JSON.stringify({payments:payments}))
}catch(err){log('Failed to write db files'.bgRed);log(err)}}*/
function dbWrite() {
const files = [{name:'dbQueue.json',data:{queue:queue}},{name:'dbUsers.json',data:{users:users}},{name:'dbPayments.json',data:{payments:payments}}]
files.forEach((file) => {
const dataExists = fs.existsSync(file.name)
const dataIsDifferent = dataExists && JSON.stringify(file.data) !== fs.readFileSync(file.name, 'utf8')
if(dataIsDifferent){
try{
fs.writeFileSync(`${file.name}.tmp.json`, JSON.stringify(file.data))
fs.renameSync(`${file.name}.tmp.json`, file.name)
}catch(err){log('Failed to write db files'.bgRed);log(err)}
}
})
}
function dbRead() {
try{
queue=JSON.parse(fs.readFileSync('dbQueue.json')).queue
users=JSON.parse(fs.readFileSync('dbUsers.json')).users
payments=JSON.parse(fs.readFileSync('dbPayments.json')).payments
} catch (err){log('Failed to read db files'.bgRed);log(err)}
}
function dbScheduleRead(){
log('read schedule db'.grey.dim)
try{
fs.readFile(dbScheduleFile,function(err,data){
if(err){console.error(err)}
var j=JSON.parse(data)
schedule=j.schedule
scheduleInit()
})
}
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, discriminator: s.admins[0].discriminator, 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)}
})
})
}
function getUser(id){var user=bot.users.get(id);log(user);if(user){return user}else{return null}}
function getUsername(id){var user=getUser(id);if(user!==null&&user.username){return user.username}else{return null}}
function 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'
axios.get(url)
.then((response)=>{hiveUsd=response.data[0].current_price;lastHiveUsd=hiveUsd;log('HIVE: $'+hiveUsd)})
.catch(()=>{
log('Failed to load data from coingecko api, trying internal market'.red.bold)
axios.post('https://rpc.ausbit.dev/', {id: 1,jsonrpc: '2.0',method: 'condenser_api.get_ticker',params: []})
.then((hresponse)=>{hiveUsd=parseFloat(hresponse.data.result.latest);log('HIVE (internal market): $'+hiveUsd)})
.catch((err)=>{log('Failed to load data from hive market api');log(err);hiveUsd=lastHiveUsd})
})
}
function getLightningInvoiceQr(memo){
var appname=config.hivePaymentAddress+'_discord' // TODO should this be an .env variable?
return 'https://api.v4v.app/v1/new_invoice_hive?hive_accname='+config.hivePaymentAddress+'&amount=1¤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 rechargePrompt(userid,channel){
userCreditCheck(userid,1) // make sure the account exists first
checkNewPayments()
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=1.000%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'
//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']
shuffle(rechargeImages)
var paymentMsgObject={
content: paymentMsg,
embeds:
[
{image:{url:rechargeImages[0]}},
{description:'Pay $1 via btc lightning network', image:{url:lightningInvoiceQr}},
{footer:{text:freeRechargeMsg}}
],
components: [
{type: Constants.ComponentTypes.ACTION_ROW, components:[
{type: 2, style: 5, label: "SEND 1 HIVE", url:paymentLinkHive, emoji: { name: 'hive', id: '1110123056501887007'}, disabled: false },
{type: 2, style: 5, label: "SEND 1 HBD", url:paymentLinkHbd, emoji: { name: 'hbd', id: '1110282940686016643'}, disabled: false },
//{type: Constants.ComponentTypes.BUTTON, style: Constants.ButtonStyles.SECONDARY, label: "Bitcoin over Lightning", custom_id: "rechargeLightning", emoji: { name: '⚡', id: null}, disabled: false }
]}
]
}
directMessageUser(userid,paymentMsgObject,channel).catch((err)=>log(err))
log('ID '+userid+' asked for recharge link')
}
async function 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=['SWAP.HBD','SWAP.HIVE']
response = await axios.get('https://history.hive-engine.com/accountHistory?account='+config.hivePaymentAddress+'&limit='+accHistoryLength+'&offset=0&ops=tokens_transfer')
var HEtransactions=response.data
var HEbotPayments=HEtransactions.filter(t=>t.to===config.hivePaymentAddress&&allowedHETokens.includes(t.symbol)&&t.memo?.startsWith(config.hivePaymentPrefix))
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&&isInteger(userid)){ // not in db yet, memo looks correct
switch(t.symbol){ // todo get market price for other tokens, keeping it simple for now
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: usdValue=0;break // shouldn't happen, just in case
}
var credits=usdValue*500
log('New Payment! credits:'.bgBrightGreen.red+credits+' , amount:'+t.quantity+' '+t.symbol+' , from: '+t.from)
creditRecharge(credits,t.transactionId,userid,t.quantity+' '+t.symbol,t.from)
}
})
}
// todo tip.cc payments
if(config.allowTipccPayments){
}
}
checkNewPayments=debounce(checkNewPayments,30000,true) // at least 30 seconds between checks
function 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 postprocessingResult(data){ // TODO unfinished, untested, awaiting new invokeai api release
log(data)
var url=data.url
url=config.basePath+data.url.split('/')[data.url.split('/').length-1]
var postRenderObject={filename: url, seed: data.metadata.image.seed, width:data.metadata.image.width,height:data.metadata.image.height}
log(postRenderObject)
//postRender(postRenderObject)
}
function requestModelChange(newmodel){log('Requesting model change to '+newmodel);if(newmodel===undefined||newmodel==='undefined'){newmodel=defaultModel}socket.emit('requestModelChange',newmodel,()=>{log('requestModelChange loaded')})}
function cancelRenders(){log('Cancelling current render'.bgRed);socket.emit('cancel');queue[queue.findIndex((q)=>q.status==='rendering')-1].status='cancelled';rendering=false}
function generationResult(data){
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})}
}
function initialImageUploaded(data){
var filename=config.basePath+"/"+data.url.replace('outputs/','')
var id=data.url.split('/')[data.url.split('/').length-1].split('.')[0]
var job=queue[id-1]
if(job){job.init_img=filename;emitRenderApi(job)}
}// response unparsed 42["imageUploaded",{"url":"outputs/init-images/002834.4241631408.postprocessed.40678651.png","mtime":1667534834.4564033,"width":1920,"height":1024,"category":"user","destination":"img2img"}]
function runPostProcessing(result, options){socket.emit('runPostProcessing',result,options)}//options={"type":"gfpgan","gfpgan_strength":0.8}
// capture result
// 42["postprocessingResult",{"url":"outputs/000313.3208696952.postprocessed.png","mtime":1665588046.4130075,"metadata":{"model":"stable diffusion","model_id":"stable-diffusion-1.4","model_hash":"fe4efff1e174c627256e44ec2991ba279b3816e364b49f9be2abc0b3ff3f8556","app_id":"lstein/stable-diffusion","app_version":"v1.15","image":{"prompt":[{"prompt":"insanely detailed. instagram photo, kodak portra. by wlop, ilya kuvshinov, krenz cushart, greg rutkowski, pixiv. zbrush sculpt, octane, maya, houdini, vfx. closeup anonymous by ayami kojima in gran turismo for ps 5 cinematic dramatic atmosphere, sharp focus, volumetric lighting","weight":1.0}],"steps":50,"cfg_scale":7.5,"threshold":0,"perlin":0,"width":512,"height":512,"seed":3208696952,"seamless":false,"postprocessing":[{"type":"gfpgan","strength":0.8}],"sampler":"k_lms","variations":[],"type":"txt2img"}}}]
//{type:'gfpgan',gfpgan_strength:0.8}
//{"type":"esrgan","upscale":[4,0.75]}
async function emitRenderApi(job){
var prompt=job.prompt
var postObject={
"prompt": prompt,
"iterations": job.number,
"steps": job.steps,
"cfg_scale": job.scale,
"threshold": job.threshold,
"perlin": job.perlin,
"sampler_name": job.sampler,
"width": job.width,
"height": job.height,
"seed": job.seed,
"progress_images": false,
"variation_amount": job.variation_amount,
"strength": job.strength,
"fit": true,
"progress_latents": true,
"generation_mode": 'txt2img',
"infill_method": 'patchmatch'
}
if(job.text_mask){
var mask_strength=0.5
if(job.mask_strength){mask_strength=job.mask_strength}
if(job.invert_mask&&job.invert_mask===true){postObject.invert_mask=true}
log('adding text mask');postObject.text_mask=[job.text_mask,mask_strength]
}
if(job.with_variations.length>0){log('adding with variations');postObject.with_variations=job.with_variations;log(postObject.with_variations)}
if(job.seamless&&job.seamless===true){postObject.seamless=true}
if(job.hires_fix&&job.hires_fix===true){postObject.hires_fix=true}
var upscale=false
var facefix=false
if(job.gfpgan_strength!==0){facefix={type:'gfpgan',strength:job.gfpgan_strength}}
if (job.codeformer_strength===undefined){job.codeformer_strength=0}
if(job.codeformer_strength!==0){facefix={type:'codeformer',strength:job.codeformer_strength,codeformer_fidelity:1}}
if(job.upscale_level!==''){upscale={level:job.upscale_level,strength:job.upscale_strength,denoise_str: 0.75}}
if(job.symh||job.symv){postObject.h_symmetry_time_pct=job.symh;postObject.v_symmetry_time_pct=job.symv}
if(job.init_img){postObject.init_img=job.init_img}
if(job&&job.model&¤tModel&&job.model!==currentModel){debugLog('job.model is different to currentModel, switching');requestModelChange(job.model)}
[postObject,upscale,facefix,job].forEach((o)=>{
var key=getObjKey(o,undefined)
if(key!==undefined){log('Missing property for '+key);if(key==='codeformer_strength'){upscale.strength=0}} // not undefined in this context means there is a key that IS undefined, confusing
})
socket.emit('generateImage',postObject,upscale,facefix)
dbWrite() // can we gracefully recover if we restart right after sending the request to backend?
//debugLog('sent request',postObject,upscale,facefix)
}
function getObjKey(obj, value){return Object.keys(obj).find(key=>obj[key]===value)}
async function addRenderApi(id){
var job=queue[queue.findIndex(x=>x.id===id)]
var initimg=null
job.status='rendering'
if(job.attachments[0]&&job.attachments[0].content_type&&job.attachments[0].content_type.startsWith('image')){
log('fetching attachment from '.bgRed + job.attachments[0].proxy_url)
await axios.get(job.attachments[0].proxy_url,{responseType: 'arraybuffer'})
.then(res=>{initimg = Buffer.from(res.data);debugLog('got attachment')})
.catch(err=>{ console.error('unable to fetch url: ' + job.attachments[0].proxy_url); console.error(err) })
}
if (initimg!==null){
debugLog('uploadInitialImage')
let form = new FormData()
form.append("data",JSON.stringify({kind:'init'}))
form.append("file",initimg,{contentType:'image/png',filename:job.id+'.png'})
function getHeaders(form) {
return new Promise((resolve, reject) => {
form.getLength((err, length) => {
if(err) { reject(err) }
let headers = Object.assign({'Content-Length': length}, form.getHeaders())
resolve(headers)
})
})
}
getHeaders(form).then((headers)=>{
return axios.post(config.apiUrl+'/upload',form, {headers:headers})
}).then((response)=>{
debugLog('initimg: '+response.data.url)
var filename=config.basePath+"/"+response.data.url.replace('outputs/','')
job.init_img=filename
job.initimg=null
emitRenderApi(job)
}).catch((error) => console.error(error))
}else{
emitRenderApi(job)
}
}
async function postRender(render){
try{fs.readFile(render.filename, null, function(err, data){
if(err){console.error(err)}else{
// TODO: OS agnostic folder seperators
// 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","") // win
//filename=render.filename.split('/')[render.filename.split('/').length-1].replace(".png","") // lin
var job=queue[queue.findIndex(x=>x.id===render.id)]
var msg=':brain:<@'+job.userid+'>'
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+'`'}
//var jobResult = job.renders[render.resultNumber]
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 newMessage = { content: msg, embeds: [{description: job.prompt, color: getRandomColorDec()}], components: [ { type: Constants.ComponentTypes.ACTION_ROW, components: [ ] } ] }
if(job.prompt.replaceAll(' ','').length===0){newMessage.embeds=[]}
newMessage.components[0].components.push({ type: Constants.ComponentTypes.BUTTON, style: Constants.ButtonStyles.SECONDARY, label: "ReDream", custom_id: "refresh-" + job.id, emoji: { name: '🎲', id: null}, disabled: false })
newMessage.components[0].components.push({ type: Constants.ComponentTypes.BUTTON, style: Constants.ButtonStyles.SECONDARY, label: "Edit Prompt", custom_id: "edit-"+job.id, emoji: { name: '✏️', id: null}, disabled: false })
newMessage.components[0].components.push({ type: Constants.ComponentTypes.BUTTON, style: Constants.ButtonStyles.SECONDARY, 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: Constants.ComponentTypes.BUTTON, style: Constants.ButtonStyles.SECONDARY, 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 filesize=fs.statSync(render.filename).size
if(filesize<defaultMaxDiscordFileSize){ // Within discord 25mb filesize limit
try{
bot.createMessage(job.channel, newMessage, {file: data, name: filename + '.png'})
.then(m=>{debugLog('Posted msg id '+m.id+' to channel id '+m.channel.id)}) // 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'})
}
}
})
}catch(err){log(err)}
}
async function repostFails(){ // bugged ? sometimes failing to repost in channels where new posts work fine // DiscordRestError [50001]: Missing Access
debugLog('Attempting to repost '+failedToPost.length+' failed message')
failedToPost.forEach((p)=>{
try{
bot.createMessage(p.channel,p.msg,{file:p.file,name:p.filename})
.then(m=>{debugLog('successfully reposted failed image');failedToPost=failedToPost.filter(f=>{f.file!==p.file})}) // remove successful posts
.catch(err=>{
log('Unable to repost to channel '+p.channel)
log(err)
})
}catch(err){log(err)}
})
}
function processQueue(){
// WIP attempt to make a harder to dominate queue
// TODO make a queueing system that prioritizes the users that have recharged the most
var queueNew=queue.filter((q)=>q.status==='new') // first alias to simplify
if(queueNew.length>0){
var queueUnique=queueNew.filter((value,index,self)=>{return self.findIndex(v=>v.userid===value.userid)===index}) // reduce to 1 entry in queue per username
var nextJobId=queueUnique[Math.floor(Math.random()*queueUnique.length)].id // random select
var nextJob=queue[queue.findIndex(x=>x.id===nextJobId)]
}else{var nextJob=queue[queue.findIndex(x=>x.status==='new')]}
if(nextJob&&!rendering){
if(userCreditCheck(nextJob.userid,costCalculator(nextJob))){
busyStatusArr=[{type:0,name:' with paint for '+queueNew.length}]
shuffle(busyStatusArr)
//var statusMsg = 'with '+queueNew.length+' artful idea';if(queueNew.length>1){statusMsg+='s'}
bot.editStatus('online',{type: busyStatusArr[0].type,name:busyStatusArr[0].name})
rendering=true
log(nextJob.username.bgWhite.red+':'+nextJob.cmd.replaceAll('\r','').replaceAll('\n').bgWhite.black)
addRenderApi(nextJob.id)
}else{
log(nextJob.username+' cant afford this render, denying')
log('cost: '+costCalculator(nextJob))
log('credits remaining: '+creditsRemaining(nextJob.userid))
nextJob.status='failed';dbWrite()
if(config.hivePaymentAddress.length>0){rechargePrompt(nextJob.userid,nextJob.channel)}else{chat('An admin can manually top up your credit with\n`!credit 1 <@'+ nextJob.userid +'>')}
processQueue()
}
}else if(nextJob&&rendering){
}else if(!nextJob&&!rendering){ // no jobs, not rendering
renderJobErrors=queue.filter((q)=>q.status==='rendering')
if(renderJobErrors.length>0){
log('These job statuses are set to rendering, but rendering=false - this shouldnt happen'.bgRed)
log(renderJobErrors)
renderJobErrors.forEach((j)=>{if(j.status==='rendering'){log('setting status to failed for id '+j.id);j.status='failed';dbWrite()}})
}
if(failedToPost.length>0){repostFails()}
debugLog('Finished queue, setting idle status'.dim)
idleStatusArr=[ // alternate idle messages
// 0=playing? 1=Playing 2=listening to 3=watching 5=competing in
{type:5,name:'meditation'},
{type:3,name:'disturbing dreams'},
{type:2,name:'your thoughts'},
{type:0,name:'the waiting game'}
]
shuffle(idleStatusArr)
bot.editStatus('idle',idleStatusArr[0])
}
}
function lexicaSearch(query,channel){
// Quick and dirty lexica search api, needs docs to make it more efficient (query limit etc)
var api = 'https://lexica.art/api/v1/search?q='+query
var link = 'https://lexica.art/?q='+require('querystring').escape(query)
var reply = {content:'Query: `'+query+'`\nTop 10 results from lexica.art api:\n**More:** '+link, embeds:[], components:[]}
axios.get(api)
.then((r)=>{
var filteredResults = r.data.images.filter(i=>i.model==='stable-diffusion')// we only care about SD results
filteredResults = filteredResults.filter((value, index, self) => {return self.findIndex(v => v.promptid === value.promptid) === index})// want only unique prompt ids
log('Lexica search for :`'+query+'` gave '+r.data.images.length+' results, '+filteredResults.length+' after filtering')
shuffle(filteredResults)
filteredResults=filteredResults.slice(0,10)// shuffle and trim to 10 results // todo make this an option once lexica writes api docs
filteredResults.forEach(i=>{reply.embeds.push({color: getRandomColorDec(),description: '',image:{url:i.srcSmall},footer:{text:i.prompt}})})
try{bot.createMessage(channel, reply)}catch(err){debugLog(err)}
})
.catch((error) => console.error(error))
}
lexicaSearch=debounce(lexicaSearch,1000,true)
async function textOverlay(imageurl,text,gravity='south',channel,user,color='white',blendmode='difference',width=false,height=125,font='Arial',extendimage=false,extendcolor='black'){
// todo caption area as a percentage of image size
try{
var image=(await axios({ url: imageurl, responseType: "arraybuffer" })).data
var res=await sharp(image)
if(extendimage){
switch(gravity){
case 'south':
case 'southeast':
case 'southwest': {res.extend({bottom:height,background:extendcolor});break}
case 'north':
case 'northeast':
case 'northwest': {res.extend({top:height,background:extendcolor});break}
}
res = sharp(await res.toBuffer()) // metadata will give wrong height value after our extend, reload it. Not too expensive
}
var metadata = await res.metadata()
if(!width){width=metadata.width-10}
const overlay = await sharp({text: {text: '<span foreground="'+color+'">'+text+'</span>',rgba: true,width: width,height: height,font: font}}).png().toBuffer()
res = await res.composite([{ input: overlay, gravity: gravity, blend: blendmode }]) // Combine the text overlay with original image
var buffer = await res.toBuffer()
bot.createMessage(channel, '<@'+user+'> added **text**: `'+text+'`\n**position**: '+gravity+', **color**:'+color+', **blendmode**:'+blendmode+', **width**:'+width+', **height**:'+height+', **font**:'+font+', **extendimage**:'+extendimage+', **extendcolor**:'+extendcolor, {file: buffer, name: user+'-'+new Date().getTime()+'-text.png'})
}catch(err){log(err)}
}
async function removeBackground(url,channel,user){
// js implementation of python rembg lib, degrades quality, needs more testing
// var image=(await axios({ url: url, responseType: "arraybuffer" })).data
// image = sharp(image)
// const remBg = new Rembg({logging: false})
// var remBgOutput = await remBg.remove(image)
// var buffer = await remBgOutput.png().toBuffer()
// original rembg python/docker version
// requires docker run -p 127.0.0.1:5000:5000 danielgatis/rembg s
var buffer = await axios.get(rembg+encodeURIComponent(url),{responseType: 'arraybuffer'})
buffer = Buffer.from(buffer.data)
var newMsg='<@'+user+'> removed background'
if(!creditsDisabled){newMsg+=' , it cost `0.05`:coin:';chargeCredits(user,0.05)}
bot.createMessage(channel, newMsg, {file: buffer, name: user+'-bg.png'})
}
async function rotate(imageurl,degrees=90,channel,user){