From 657a261678161e287fa483c90af50ac31a35d975 Mon Sep 17 00:00:00 2001 From: oelburk Date: Tue, 25 Jul 2023 23:55:08 +0200 Subject: [PATCH 1/7] Update dependencies, refactor beer agent to module --- bin/beer_bot.dart | 118 +------- bin/beerlist.dart | 19 -- bin/commands.dart | 241 +-------------- bin/modules/beer_agent/beer_agent_module.dart | 278 ++++++++++++++++++ bin/modules/beer_agent/commands.dart | 176 +++++++++++ bin/{ => modules/beer_agent/models}/beer.dart | 19 ++ bin/modules/bot_module.dart | 8 + bin/untapped_service.dart | 95 ------ bin/utils.dart | 62 +--- pubspec.yaml | 19 +- 10 files changed, 510 insertions(+), 525 deletions(-) delete mode 100644 bin/beerlist.dart create mode 100644 bin/modules/beer_agent/beer_agent_module.dart create mode 100644 bin/modules/beer_agent/commands.dart rename bin/{ => modules/beer_agent/models}/beer.dart (71%) create mode 100644 bin/modules/bot_module.dart delete mode 100644 bin/untapped_service.dart diff --git a/bin/beer_bot.dart b/bin/beer_bot.dart index 7cf1ccd..f7dacf4 100644 --- a/bin/beer_bot.dart +++ b/bin/beer_bot.dart @@ -1,22 +1,17 @@ import 'dart:async'; +import 'dart:io'; + import 'package:hive/hive.dart'; import 'package:nyxx/nyxx.dart'; -import 'package:http/http.dart' as http; import 'package:nyxx_interactions/nyxx_interactions.dart'; -import 'dart:convert'; -import 'dart:io'; -import 'beer.dart'; -import 'beerlist.dart'; + import 'commands.dart'; import 'constants/hive_constants.dart'; -import 'untapped_service.dart'; -import 'utils.dart'; -import 'package:intl/intl.dart'; +import 'modules/beer_agent/beer_agent_module.dart'; +import 'modules/untappd/untapped_service.dart'; String BOT_TOKEN = Platform.environment['DISCORD_TOKEN'] ?? ''; -late final Stopwatch ELAPSED_SINCE_UPDATE; -List BEER_SALES = []; -int REFRESH_THRESHOLD = 14400000; + late final INyxxWebsocket bot; void main(List arguments) { @@ -38,19 +33,18 @@ void main(List arguments) { interactions.syncOnReady(); - ELAPSED_SINCE_UPDATE = Stopwatch(); - bot.eventsWs.onReady.listen((e) { print('Agent Hops is ready!'); }); - Timer.periodic(Duration(hours: 6), (timer) => updateSubscribers()); + // Initialize bot modules + BeerAgentModule().init(bot); Timer.periodic(Duration(minutes: 12), (timer) => checkUntappd()); } void checkUntappd() async { - var box = await Hive.box(HiveConstants.untappdBox); + var box = Hive.box(HiveConstants.untappdBox); Map listOfUsers = await box.get(HiveConstants.untappdUserList, defaultValue: {}); @@ -125,97 +119,3 @@ String _buildRatingEmoji(double rating) { } return '$ratingString ($rating)'; } - -Future updateSubscribers() async { - await requestBeer(); - - var myFile = File('sub.dat'); - var shouldInform = false; - var beers = []; - var saleDate; - - if (!await myFile.exists()) return; - - for (var sale in BEER_SALES) { - saleDate = DateTime.parse(sale.saleDate); - var currentDate = - DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day); - if (saleDate.difference(currentDate).inDays == 1) { - //Inform subscribing users about upcoming sale... - shouldInform = true; - print('Sale is going down!'); - beers = sale.beerList; - break; - } - //No sale is closer than 1 day -> do nothing... - } - - if (shouldInform) { - for (var dmchannel in await getSubChannels(bot)) { - var beersStr = ''; - beers.forEach((element) { - beersStr += '- ' + element.name + '\n'; - }); - - var updateMessage = MessageBuilder() - ..append(':beers: Hey!') - ..appendNewLine() - ..append('There is a fresh beer release tomorrow, ') - ..appendBold(DateFormat('yyyy-MM-dd').format(saleDate)) - ..append('. Bolaget opens 10:00') - ..appendNewLine() - ..append('There are ') - ..appendBold(beers.length.toString()) - ..append(' new beers tomorrow.') - ..appendNewLine() - ..append('For more info, visit https://systembevakningsagenten.se/') - ..appendNewLine() - ..appendNewLine() - ..append(beersStr); - - //To avoid hitting maximum characters for a message, limit output to 2000. - if (updateMessage.toString().length > 2000) { - updateMessage.content = - updateMessage.content.substring(0, 1992) + '...\n\n'; - } - await dmchannel.sendMessage(updateMessage); - } - } else { - print('No sale, boring...'); - } -} - -Future requestBeer() async { - //Only update list if older than 4 hours or empty - if (ELAPSED_SINCE_UPDATE.elapsedMilliseconds > REFRESH_THRESHOLD || - BEER_SALES.isEmpty) { - ELAPSED_SINCE_UPDATE.stop(); - print('Updating beer releases and beers...'); - final list = await fetchBeerList(); - BEER_SALES.clear(); - for (var item in list['release']) { - BEER_SALES.add(BeerList.fromJson(item)); - } - ELAPSED_SINCE_UPDATE.reset(); - ELAPSED_SINCE_UPDATE.start(); - } else { - print('No update needed, requires update in ' + - (((REFRESH_THRESHOLD - ELAPSED_SINCE_UPDATE.elapsedMilliseconds) / - 1000) ~/ - 60) - .toString() + - ' minutes.'); - } -} - -Future> fetchBeerList() async { - final response = await http.get(Uri.parse( - 'https://systembevakningsagenten.se/api/json/2.0/newProducts.json')); - - if (response.statusCode == 200) { - Map res = json.decode(response.body); - return res; - } else { - throw Exception('Error fetching beer information'); - } -} diff --git a/bin/beerlist.dart b/bin/beerlist.dart deleted file mode 100644 index d97c215..0000000 --- a/bin/beerlist.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'beer.dart'; - -class BeerList { - final String saleDate; - final List beerList; - - BeerList(this.saleDate, this.beerList); - BeerList.fromJson(Map json) - : saleDate = json['first_sale'], - beerList = createListFromMap(json['items']); - - static List createListFromMap(List json) { - var toReturn = []; - for (var item in json) { - toReturn.add(Beer.fromJson(item)); - } - return toReturn; - } -} diff --git a/bin/commands.dart b/bin/commands.dart index 078b831..899881f 100644 --- a/bin/commands.dart +++ b/bin/commands.dart @@ -1,10 +1,9 @@ import 'package:hive/hive.dart'; -import 'package:intl/intl.dart'; import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; -import 'beer_bot.dart'; import 'constants/hive_constants.dart'; +import 'modules/beer_agent/beer_agent_module.dart'; import 'utils.dart'; class Commands { @@ -17,42 +16,7 @@ class Commands { await event.acknowledge(); await _helpCommand(event); }), - SlashCommandBuilder( - 'oel', - 'Show the latest beer releases.', - [], - )..registerHandler((event) async { - await event.acknowledge(); - await _oelCommand(event); - }), - SlashCommandBuilder( - 'subscribe', - 'Subscribe to beer release reminders.', - [], - )..registerHandler((event) async { - await event.acknowledge(); - await _regCommand(event); - }), - SlashCommandBuilder( - 'stop', - 'Unsubscribe to beer release reminders.', - [], - )..registerHandler((event) async { - await event.acknowledge(); - await _stopCommand(event); - }), - SlashCommandBuilder( - 'release', - 'Detailed info about a specific beer release e.g. /release 2022-07-15', - [ - CommandOptionBuilder( - CommandOptionType.string, 'datum', 'YYYY-MM-dd', - required: true), - ], - )..registerHandler((event) async { - await event.acknowledge(); - await _releaseCommand(event); - }), + ...BeerAgentModule().commands, SlashCommandBuilder( 'untappd', 'Let me know your untappd username so I can post automatic updates from your untappd account.', @@ -83,22 +47,7 @@ class Commands { ..append('Did anyone say beer? This is what I can do for you:') ..appendNewLine() ..appendNewLine() - ..appendBold('/oel') - ..appendNewLine() - ..append('Lists all known beer releases.') - ..appendNewLine() - ..appendNewLine() - ..appendBold('/subscribe') - ..appendNewLine() - ..append( - 'Subscribe to automatic beer release reminders. Reminders will be posted 3 times during the day before release.') - ..appendNewLine() - ..appendNewLine() - ..appendBold('/release YYYY-MM-dd') - ..appendNewLine() - ..append( - 'Posts the beer release for given date in the format YYYY-MM-dd. e.g ') - ..appendItalics('/release 1970-01-30') + ..append(BeerAgentModule().helpMessage) ..appendNewLine() ..appendNewLine() ..appendBold('/help') @@ -121,186 +70,8 @@ class Commands { await ctx.respond(helpMessage); } - static Future _regCommand(ISlashCommandInteractionEvent ctx) async { - var dmChan = await ctx.interaction.userAuthor!.dmChannel; - - if (await isUserSubbed(bot, dmChan.id)) { - await ctx.respond(MessageBuilder.content( - ctx.interaction.userAuthor!.mention + - ' You are already subscribed! :beers:')); - } else { - await subUser(dmChan.id); - - await ctx.respond(MessageBuilder.content( - ctx.interaction.userAuthor!.mention + - ' You are now subscribed to beer release reminders! :beers:')); - } - } - - static Future _stopCommand(ISlashCommandInteractionEvent ctx) async { - var dmChan = await ctx.interaction.userAuthor!.dmChannel; - - if (await isUserSubbed(bot, dmChan.id)) { - await unsubUser(bot, dmChan.id); - - await ctx.respond(MessageBuilder.content( - ctx.interaction.userAuthor!.mention + - ' Sad, no more beer for you! :beers:')); - } else { - await ctx.respond(MessageBuilder.content( - ctx.interaction.userAuthor!.mention + - ' You are not subscribed! :beers:')); - } - } - - static Future _oelCommand(ISlashCommandInteractionEvent ctx) async { - //Updates current beer list if needed - await requestBeer(); - - //Build message - var oelMessage = MessageBuilder() - ..append(ctx.interaction.userAuthor!.mention) - ..appendNewLine() - ..append('There are ') - ..appendBold(BEER_SALES.length.toString()) - ..append(' current releases!') - ..appendNewLine() - ..appendNewLine(); - - for (var beerSale in BEER_SALES) { - var saleDate = beerSale.saleDate; - var saleSize = beerSale.beerList.length; - beerSale.beerList.shuffle(); - - if (saleSize >= 3) { - oelMessage - ..append(':beer: ') - ..appendBold(saleDate) - ..appendNewLine() - ..append('This release has ') - ..appendBold(saleSize) - ..append(' new beers!') - ..appendNewLine() - ..appendNewLine() - ..append('Some of them are:') - ..appendNewLine() - ..append('- ') - ..appendBold(beerSale.beerList[0].name) - ..appendNewLine() - ..append('- ') - ..appendBold(beerSale.beerList[1].name) - ..appendNewLine() - ..append('- ') - ..appendBold(beerSale.beerList[2].name) - ..appendNewLine() - ..appendNewLine(); - } else if (saleSize == 2) { - oelMessage - ..append(':beer: ') - ..appendBold(saleDate) - ..appendNewLine() - ..append('This release has ') - ..appendBold(saleSize) - ..append(' new beers!') - ..appendNewLine() - ..appendNewLine() - ..append('Some of them are:') - ..appendNewLine() - ..append('- ') - ..appendBold(beerSale.beerList[0].name) - ..appendNewLine() - ..append('- ') - ..appendBold(beerSale.beerList[1].name) - ..appendNewLine() - ..appendNewLine(); - } else if (saleSize == 1) { - oelMessage - ..append(':beer: ') - ..appendBold(saleDate) - ..appendNewLine() - ..append('This release has ') - ..appendBold(saleSize) - ..append(' new beer!') - ..appendNewLine() - ..appendNewLine() - ..append('- ') - ..appendBold(beerSale.beerList[0].name) - ..appendNewLine() - ..appendNewLine(); - } - } - - oelMessage - ..append('---') - ..appendNewLine() - ..append('For more information: https://systembevakningsagenten.se/') - ..appendNewLine() - ..appendNewLine() - ..append('Cheers! :beers:'); - - //Send message - await ctx.respond(oelMessage); - } - - static Future _releaseCommand(ISlashCommandInteractionEvent ctx) async { - var input = ctx.args; - if (input.length == 1) { - var parsedDate = DateTime.tryParse(input[0].value); - - if (parsedDate != null) { - await requestBeer(); - for (var sale in BEER_SALES) { - var saleDate = DateTime.parse(sale.saleDate); - if (parsedDate == saleDate) { - //Compile beer list to string and sort by name. - var beerStr = ''; - sale.beerList.sort((a, b) => a.name.compareTo(b.name)); - sale.beerList.forEach((element) { - beerStr += '- ' + element.name + '\n'; - }); - - //Bulild reply - var slappMessage = MessageBuilder() - ..append(ctx.interaction.userAuthor!.mention) - ..appendNewLine() - ..append(' :beers: ') - ..appendBold(input[0].value) - ..appendNewLine() - ..append('Innehåller ') - ..appendBold(sale.beerList.length) - ..append(' nya öl:') - ..appendNewLine() - ..appendNewLine() - ..append(beerStr); - - if (slappMessage.content.length > 2000) { - slappMessage.content = slappMessage.content.substring( - 0, - slappMessage.content - .substring(0, 1999) - .lastIndexOf('- ') - - 1) + - '\n...'; - } - await ctx.respond(slappMessage); - return; - } - } - await ctx.respond(MessageBuilder.content( - ctx.interaction.userAuthor!.mention + - ' Fanns inget ölsläpp för ' + - DateFormat('yyyy-MM-dd').format(parsedDate))); - return; - } - } - - await ctx.respond(MessageBuilder.content( - ctx.interaction.userAuthor!.mention + - ' Are you drunk buddy? I only accept ***/release YYYY-MM-dd***')); - } - static Future _untappdCommand(ISlashCommandInteractionEvent ctx) async { - var box = await Hive.box(HiveConstants.untappdBox); + var box = Hive.box(HiveConstants.untappdBox); if (box.get(HiveConstants.untappdUpdateChannelId) == null) { await ctx.respond(MessageBuilder.content( ctx.interaction.userAuthor!.mention + @@ -312,7 +83,7 @@ class Commands { ctx.interaction.userAuthor!.mention + ' Are you drunk buddy? Your username is missing.')); } - var discordUser = await ctx.interaction.userAuthor!.id; + var discordUser = ctx.interaction.userAuthor!.id; var untappdUsername = ctx.args.first.value; if (!await regUntappdUser(discordUser, untappdUsername)) { @@ -330,7 +101,7 @@ class Commands { if (ctx.interaction.memberAuthorPermissions?.administrator ?? false) { var beerUpdateChannel = await ctx.interaction.channel.getOrDownload(); - var box = await Hive.box(HiveConstants.untappdBox); + var box = Hive.box(HiveConstants.untappdBox); await box.put(HiveConstants.untappdUpdateChannelId, beerUpdateChannel.id.toString()); diff --git a/bin/modules/beer_agent/beer_agent_module.dart b/bin/modules/beer_agent/beer_agent_module.dart new file mode 100644 index 0000000..5548ebf --- /dev/null +++ b/bin/modules/beer_agent/beer_agent_module.dart @@ -0,0 +1,278 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:intl/intl.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; + +import '../bot_module.dart'; +import 'models/beer.dart'; + +part 'commands.dart'; + +class BeerAgentModule extends BotModule { + late final Stopwatch _elapsedSinceUpdate; + final List _beerSales = []; + final int _refreshThreshold = 14400000; + + bool _isInitialized = false; + + late INyxxWebsocket _bot; + + static final BeerAgentModule _singleton = BeerAgentModule._internal(); + factory BeerAgentModule() { + return _singleton; + } + BeerAgentModule._internal(); + + /// Updates the list of beer sales and informs subscribers about upcoming sales. + Future _updateSubscribers() async { + await _updateBeerSales(); + + var myFile = File('sub.dat'); + var shouldInform = false; + var beers = []; + var saleDate; + + if (!await myFile.exists()) return; + + for (var sale in _beerSales) { + saleDate = DateTime.parse(sale.saleDate); + var currentDate = DateTime( + DateTime.now().year, DateTime.now().month, DateTime.now().day); + if (saleDate.difference(currentDate).inDays == 1) { + //Inform subscribing users about upcoming sale... + shouldInform = true; + print('Sale is going down!'); + beers = sale.beerList; + break; + } + //No sale is closer than 1 day -> do nothing... + } + + if (shouldInform) { + for (var dmchannel in await _getSubChannels(_bot)) { + var beersStr = ''; + beers.forEach((element) { + beersStr += '- ' + element.name + '\n'; + }); + + var updateMessage = MessageBuilder() + ..append(':beers: Hey!') + ..appendNewLine() + ..append('There is a fresh beer release tomorrow, ') + ..appendBold(DateFormat('yyyy-MM-dd').format(saleDate)) + ..append('. Bolaget opens 10:00') + ..appendNewLine() + ..append('There are ') + ..appendBold(beers.length.toString()) + ..append(' new beers tomorrow.') + ..appendNewLine() + ..append('For more info, visit https://systembevakningsagenten.se/') + ..appendNewLine() + ..appendNewLine() + ..append(beersStr); + + //To avoid hitting maximum characters for a message, limit output to 2000. + if (updateMessage.toString().length > 2000) { + updateMessage.content = + updateMessage.content.substring(0, 1992) + '...\n\n'; + } + await dmchannel.sendMessage(updateMessage); + } + } else { + print('No sale, boring...'); + } + } + + /// Fetches a list of all beer sales from online API. + Future> _fetchBeerList() async { + final response = await http.get(Uri.parse( + 'https://systembevakningsagenten.se/api/json/2.0/newProducts.json')); + + if (response.statusCode == 200) { + Map res = json.decode(response.body); + return res; + } else { + throw Exception('Error fetching beer information'); + } + } + + /// Returns a list of all channels (users) that are subscribed to beer updates. + Future> _getSubChannels(INyxxWebsocket bot) async { + var myFile = File('sub.dat'); + var channelList = []; + + var fileExists = await myFile.exists(); + if (!fileExists) await myFile.create(); + + await myFile.readAsLines().then((value) async { + for (var line in value) { + var chan = await bot + .fetchChannel(Snowflake(line)) + .then((value) => (value as IDMChannel)); + + channelList.add(chan); + } + }); + return channelList; + } + + /// Updates the list of beer sales. + Future _updateBeerSales() async { + if (!_isInitialized) { + print('Beer agent service not initialized!'); + throw Exception('Beer agent service not initialized!'); + } + + //Only update list if older than 4 hours or empty + if (_elapsedSinceUpdate.elapsedMilliseconds > _refreshThreshold || + _beerSales.isEmpty) { + _elapsedSinceUpdate.stop(); + print('Updating beer releases and beers...'); + final list = await _fetchBeerList(); + _beerSales.clear(); + for (var item in list['release']) { + _beerSales.add(BeerList.fromJson(item)); + } + _elapsedSinceUpdate.reset(); + _elapsedSinceUpdate.start(); + } else { + print('No update needed, requires update in ' + + (((_refreshThreshold - _elapsedSinceUpdate.elapsedMilliseconds) / + 1000) ~/ + 60) + .toString() + + ' minutes.'); + } + } + + /// Checks if a user is subscribed to beer updates. + Future _isUserSubbed(Snowflake userSnowflake) async { + if (!_isInitialized) { + print('Beer agent service not initialized!'); + throw Exception('Beer agent service not initialized!'); + } + + var subs = await _getSubChannels(_bot); + + if (subs.asSnowflakes().contains(userSnowflake)) { + return true; + } else { + return false; + } + } + + /// Unsubscribes a user from beer updates. + Future _unsubUser(Snowflake userSnowflake) async { + if (!_isInitialized) { + print('Beer agent service not initialized!'); + throw Exception('Beer agent service not initialized!'); + } + + var currentSubs = await _getSubChannels(_bot); + + currentSubs.removeWhere((element) => element.id == userSnowflake); + var tempFile = File('temp.dat'); + var subFile = File('sub.dat'); + + await tempFile.create(); + + currentSubs.forEach((element) async { + await tempFile.writeAsString(element.id.toString(), + mode: FileMode.append); + }); + + await subFile.writeAsBytes(await tempFile.readAsBytes()); + + await tempFile.delete(); + } + + /// Subscribes a user to beer updates. + Future _subUser(Snowflake userSnowflake) async { + if (!_isInitialized) { + print('Beer agent service not initialized!'); + throw Exception('Beer agent service not initialized!'); + } + var myFile = File('sub.dat'); + await myFile.writeAsString(userSnowflake.toString(), mode: FileMode.append); + } + + /// Initializes the beer agent service. + @override + void init(INyxxWebsocket bot) { + _bot = bot; + _elapsedSinceUpdate = Stopwatch(); + _elapsedSinceUpdate.start(); + + Timer.periodic(Duration(minutes: 5), (timer) => _updateSubscribers()); + + _isInitialized = true; + } + + /// Returns a list of all slash commands for the beer agent module. + @override + List get commands => [ + SlashCommandBuilder( + 'oel', + 'Show the latest beer releases.', + [], + )..registerHandler((event) async { + await event.acknowledge(); + await _oelCommand(event); + }), + SlashCommandBuilder( + 'subscribe', + 'Subscribe to beer release reminders.', + [], + )..registerHandler((event) async { + await event.acknowledge(); + await _regCommand(event); + }), + SlashCommandBuilder( + 'stop', + 'Unsubscribe to beer release reminders.', + [], + )..registerHandler((event) async { + await event.acknowledge(); + await _stopCommand(event); + }), + SlashCommandBuilder( + 'release', + 'Detailed info about a specific beer release e.g. /release 2022-07-15', + [ + CommandOptionBuilder( + CommandOptionType.string, 'datum', 'YYYY-MM-dd', + required: true), + ], + )..registerHandler((event) async { + await event.acknowledge(); + await _releaseCommand(event); + }), + ]; + + /// Returns a help message for the beer agent module. + @override + MessageBuilder get helpMessage => MessageBuilder() + ..appendBold('/oel') + ..appendNewLine() + ..append('Lists all known beer releases.') + ..appendNewLine() + ..appendNewLine() + ..appendBold('/subscribe') + ..appendNewLine() + ..append( + 'Subscribe to automatic beer release reminders. Reminders will be posted 3 times during the day before release.') + ..appendNewLine() + ..appendNewLine() + ..appendBold('/release YYYY-MM-dd') + ..appendNewLine() + ..append( + 'Posts the beer release for given date in the format YYYY-MM-dd. e.g ') + ..appendItalics('/release 1970-01-30'); + + /// Returns a list of all current beer sales. + List get beerSales => _beerSales; +} diff --git a/bin/modules/beer_agent/commands.dart b/bin/modules/beer_agent/commands.dart new file mode 100644 index 0000000..55783c9 --- /dev/null +++ b/bin/modules/beer_agent/commands.dart @@ -0,0 +1,176 @@ +part of 'beer_agent_module.dart'; + +Future _regCommand(ISlashCommandInteractionEvent ctx) async { + var dmChan = await ctx.interaction.userAuthor!.dmChannel; + + if (await BeerAgentModule()._isUserSubbed(dmChan.id)) { + await ctx.respond(MessageBuilder.content( + ctx.interaction.userAuthor!.mention + + ' You are already subscribed! :beers:')); + } else { + await BeerAgentModule()._subUser(dmChan.id); + + await ctx.respond(MessageBuilder.content( + ctx.interaction.userAuthor!.mention + + ' You are now subscribed to beer release reminders! :beers:')); + } +} + +Future _stopCommand(ISlashCommandInteractionEvent ctx) async { + var dmChan = await ctx.interaction.userAuthor!.dmChannel; + + if (await BeerAgentModule()._isUserSubbed(dmChan.id)) { + await BeerAgentModule()._unsubUser(dmChan.id); + + await ctx.respond(MessageBuilder.content( + ctx.interaction.userAuthor!.mention + + ' Sad, no more beer for you! :beers:')); + } else { + await ctx.respond(MessageBuilder.content( + ctx.interaction.userAuthor!.mention + + ' You are not subscribed! :beers:')); + } +} + +Future _oelCommand(ISlashCommandInteractionEvent ctx) async { + //Updates current beer list if needed + await BeerAgentModule()._updateBeerSales(); + + //Build message + var oelMessage = MessageBuilder() + ..append(ctx.interaction.userAuthor!.mention) + ..appendNewLine() + ..append('There are ') + ..appendBold(BeerAgentModule().beerSales.length.toString()) + ..append(' current releases!') + ..appendNewLine() + ..appendNewLine(); + + for (var beerSale in BeerAgentModule().beerSales) { + var saleDate = beerSale.saleDate; + var saleSize = beerSale.beerList.length; + beerSale.beerList.shuffle(); + + if (saleSize >= 3) { + oelMessage + ..append(':beer: ') + ..appendBold(saleDate) + ..appendNewLine() + ..append('This release has ') + ..appendBold(saleSize) + ..append(' new beers!') + ..appendNewLine() + ..appendNewLine() + ..append('Some of them are:') + ..appendNewLine() + ..append('- ') + ..appendBold(beerSale.beerList[0].name) + ..appendNewLine() + ..append('- ') + ..appendBold(beerSale.beerList[1].name) + ..appendNewLine() + ..append('- ') + ..appendBold(beerSale.beerList[2].name) + ..appendNewLine() + ..appendNewLine(); + } else if (saleSize == 2) { + oelMessage + ..append(':beer: ') + ..appendBold(saleDate) + ..appendNewLine() + ..append('This release has ') + ..appendBold(saleSize) + ..append(' new beers!') + ..appendNewLine() + ..appendNewLine() + ..append('Some of them are:') + ..appendNewLine() + ..append('- ') + ..appendBold(beerSale.beerList[0].name) + ..appendNewLine() + ..append('- ') + ..appendBold(beerSale.beerList[1].name) + ..appendNewLine() + ..appendNewLine(); + } else if (saleSize == 1) { + oelMessage + ..append(':beer: ') + ..appendBold(saleDate) + ..appendNewLine() + ..append('This release has ') + ..appendBold(saleSize) + ..append(' new beer!') + ..appendNewLine() + ..appendNewLine() + ..append('- ') + ..appendBold(beerSale.beerList[0].name) + ..appendNewLine() + ..appendNewLine(); + } + } + + oelMessage + ..append('---') + ..appendNewLine() + ..append('For more information: https://systembevakningsagenten.se/') + ..appendNewLine() + ..appendNewLine() + ..append('Cheers! :beers:'); + + //Send message + await ctx.respond(oelMessage); +} + +Future _releaseCommand(ISlashCommandInteractionEvent ctx) async { + var input = ctx.args; + if (input.length == 1) { + var parsedDate = DateTime.tryParse(input[0].value); + + if (parsedDate != null) { + await BeerAgentModule()._updateBeerSales(); + for (var sale in BeerAgentModule().beerSales) { + var saleDate = DateTime.parse(sale.saleDate); + if (parsedDate == saleDate) { + //Compile beer list to string and sort by name. + var beerStr = ''; + sale.beerList.sort((a, b) => a.name.compareTo(b.name)); + sale.beerList.forEach((element) { + beerStr += '- ' + element.name + '\n'; + }); + + //Bulild reply + var slappMessage = MessageBuilder() + ..append(ctx.interaction.userAuthor!.mention) + ..appendNewLine() + ..append(' :beers: ') + ..appendBold(input[0].value) + ..appendNewLine() + ..append('Innehåller ') + ..appendBold(sale.beerList.length) + ..append(' nya öl:') + ..appendNewLine() + ..appendNewLine() + ..append(beerStr); + + if (slappMessage.content.length > 2000) { + slappMessage.content = slappMessage.content.substring( + 0, + slappMessage.content.substring(0, 1999).lastIndexOf('- ') - + 1) + + '\n...'; + } + await ctx.respond(slappMessage); + return; + } + } + await ctx.respond(MessageBuilder.content( + ctx.interaction.userAuthor!.mention + + ' Fanns inget ölsläpp för ' + + DateFormat('yyyy-MM-dd').format(parsedDate))); + return; + } + } + + await ctx.respond(MessageBuilder.content(ctx.interaction.userAuthor!.mention + + ' Are you drunk buddy? I only accept ***/release YYYY-MM-dd***')); +} diff --git a/bin/beer.dart b/bin/modules/beer_agent/models/beer.dart similarity index 71% rename from bin/beer.dart rename to bin/modules/beer_agent/models/beer.dart index 8b0b1c8..84416e0 100644 --- a/bin/beer.dart +++ b/bin/modules/beer_agent/models/beer.dart @@ -1,4 +1,5 @@ import 'dart:math'; + import 'package:nyxx/nyxx.dart'; class Beer { @@ -45,3 +46,21 @@ class Beer { return title; } } + +class BeerList { + final String saleDate; + final List beerList; + + BeerList(this.saleDate, this.beerList); + BeerList.fromJson(Map json) + : saleDate = json['first_sale'], + beerList = createListFromMap(json['items']); + + static List createListFromMap(List json) { + var toReturn = []; + for (var item in json) { + toReturn.add(Beer.fromJson(item)); + } + return toReturn; + } +} diff --git a/bin/modules/bot_module.dart b/bin/modules/bot_module.dart new file mode 100644 index 0000000..940f49d --- /dev/null +++ b/bin/modules/bot_module.dart @@ -0,0 +1,8 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; + +abstract class BotModule { + void init(INyxxWebsocket bot); + List get commands; + MessageBuilder get helpMessage; +} diff --git a/bin/untapped_service.dart b/bin/untapped_service.dart deleted file mode 100644 index 044ed0c..0000000 --- a/bin/untapped_service.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:web_scraper/web_scraper.dart'; - -class UntappdService { - /// Check validity of the username provided - /// - /// Will return true if given username has at least one checkin on Untappd. - static Future isValidUsername(String untappdUsername) async { - final webScraper = WebScraper('https://untappd.com'); - if (await webScraper.loadWebPage('/user/$untappdUsername')) { - final checkins = webScraper.getElementAttribute( - 'div#main-stream > *', 'data-checkin-id'); - - if (checkins.isEmpty) { - return false; - } - return true; - } else { - throw 'Error during fetching of Untappd data'; - } - } - - /// Get latest checkin for given username - static Future getLatestCheckin( - String untappdUsername) async { - final webScraper = WebScraper('https://untappd.com'); - if (await webScraper.loadWebPage('/user/$untappdUsername')) { - final checkins = webScraper.getElementAttribute( - 'div#main-stream > *', 'data-checkin-id'); - - if (checkins.isEmpty) { - throw 'No checkins are available for $untappdUsername'; - } - - var latestCheckin = checkins.first!; - - var baseCheckinAddress = - 'div#main-stream > #checkin_$latestCheckin > div.checkin > div.top'; - - final checkinTitleElement = - webScraper.getElementTitle('$baseCheckinAddress > p.text'); - final checkinTitle = - checkinTitleElement.isEmpty ? '' : checkinTitleElement.first.trim(); - - final checkinRatingElement = webScraper.getElement( - '$baseCheckinAddress > div.checkin-comment > div.rating-serving > div.caps ', - ['data-rating']); - final String checkinRating = checkinRatingElement.isEmpty - ? '0' - : checkinRatingElement.first['attributes']['data-rating']; - - final checkinCommentElement = webScraper.getElementTitle( - '$baseCheckinAddress > div.checkin-comment > p.comment-text'); - final checkinComment = checkinCommentElement.isEmpty - ? '' - : checkinCommentElement.first.trim(); - - final photo = webScraper.getElementAttribute( - '$baseCheckinAddress > p.photo > a > img', 'data-original'); - final checkinPhotoAddress = photo.isNotEmpty ? photo.first : null; - - return UntappdCheckin( - id: latestCheckin, - title: checkinTitle, - rating: checkinRating, - comment: checkinComment, - photoAddress: checkinPhotoAddress); - } - return null; - } - - /// Get untappd detailed checkin URL - static String getCheckinUrl(String checkinId, String username) { - return 'https://untappd.com/user/$username/checkin/$checkinId'; - } -} - -class UntappdCheckin { - const UntappdCheckin({ - required this.id, - required this.title, - required this.rating, - required this.comment, - this.photoAddress, - }); - final String id; - final String title; - final String rating; - final String comment; - final String? photoAddress; - - @override - String toString() { - return 'title: $title\nrating: $rating\ncomment: $comment\nphoto url: $photoAddress\n'; - } -} diff --git a/bin/utils.dart b/bin/utils.dart index 95a3c24..65bb1f9 100644 --- a/bin/utils.dart +++ b/bin/utils.dart @@ -1,11 +1,11 @@ +import 'dart:convert'; + import 'package:hive/hive.dart'; import 'package:http/http.dart' as http; -import 'dart:convert'; import 'package:nyxx/nyxx.dart'; -import 'dart:io'; import 'constants/hive_constants.dart'; -import 'untapped_service.dart'; +import 'modules/untappd/untapped_service.dart'; Future> httpGetRequest(String getURL) async { final response = await http.get(Uri.parse(getURL)); @@ -18,60 +18,8 @@ Future> httpGetRequest(String getURL) async { } } -Future> getSubChannels(INyxxWebsocket bot) async { - var myFile = File('sub.dat'); - var channelList = []; - - var fileExists = await myFile.exists(); - if (!fileExists) await myFile.create(); - - await myFile.readAsLines().then((value) async { - for (var line in value) { - var chan = await bot - .fetchChannel(Snowflake(line)) - .then((value) => (value as IDMChannel)); - - channelList.add(chan); - } - }); - return channelList; -} - -Future isUserSubbed(INyxxWebsocket bot, Snowflake userSnowflake) async { - var subs = await getSubChannels(bot); - - if (subs.asSnowflakes().contains(userSnowflake)) { - return true; - } else { - return false; - } -} - -Future unsubUser(INyxxWebsocket bot, Snowflake userSnowflake) async { - var currentSubs = await getSubChannels(bot); - - currentSubs.removeWhere((element) => element.id == userSnowflake); - var tempFile = File('temp.dat'); - var subFile = File('sub.dat'); - - await tempFile.create(); - - currentSubs.forEach((element) async { - await tempFile.writeAsString(element.id.toString(), mode: FileMode.append); - }); - - await subFile.writeAsBytes(await tempFile.readAsBytes()); - - await tempFile.delete(); -} - -Future subUser(Snowflake userSnowflake) async { - var myFile = File('sub.dat'); - await myFile.writeAsString(userSnowflake.toString(), mode: FileMode.append); -} - Future isUserUntappdRegistered(Snowflake userSnowflake) async { - var box = await Hive.box(HiveConstants.untappdBox); + var box = Hive.box(HiveConstants.untappdBox); Map userList = box.get(HiveConstants.untappdUserList, defaultValue: {}); return userList.keys.contains(userSnowflake); @@ -80,7 +28,7 @@ Future isUserUntappdRegistered(Snowflake userSnowflake) async { Future regUntappdUser( Snowflake userSnowflake, String untappdUsername) async { try { - var box = await Hive.box(HiveConstants.untappdBox); + var box = Hive.box(HiveConstants.untappdBox); if (!await UntappdService.isValidUsername(untappdUsername)) { print('No checkins available for user, ignoring add.'); diff --git a/pubspec.yaml b/pubspec.yaml index 72999dc..c4f717c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,18 +4,17 @@ version: 1.0.0 homepage: https://github.com/oelburk/agentsbot environment: - sdk: '>=2.17.1 <3.0.0' + sdk: ">=3.0.0 <4.0.0" -dependencies: +dependencies: hive: ^2.2.3 - intl: ^0.17.0 - nyxx: ^3.2.3 - nyxx_commander: ^3.0.0 - nyxx_interactions: ^4.2.1 + intl: ^0.18.1 + nyxx: ^5.1.0 + nyxx_commands: ^5.0.2 + nyxx_interactions: ^4.6.0 web_scraper: ^0.1.4 - dev_dependencies: - lints: ^2.0.0 - test: ^1.16.0 - pedantic: ^1.9.0 + lints: ^2.1.1 + test: ^1.24.4 + pedantic: ^1.11.1 From fbfd3c62774a4573cf4b5286d94391e4258b6464 Mon Sep 17 00:00:00 2001 From: oelburk Date: Wed, 26 Jul 2023 00:56:04 +0200 Subject: [PATCH 2/7] Refactor untappd service to module --- bin/beer_bot.dart | 85 +----- bin/commands.dart | 91 +------ bin/modules/beer_agent/beer_agent_module.dart | 3 - bin/modules/bot_module.dart | 5 + bin/modules/untappd/commands.dart | 40 +++ .../untappd}/hive_constants.dart | 0 .../untappd/models/untappd_checkin.dart | 19 ++ bin/modules/untappd/untapped_module.dart | 243 ++++++++++++++++++ bin/utils.dart | 32 --- 9 files changed, 321 insertions(+), 197 deletions(-) create mode 100644 bin/modules/untappd/commands.dart rename bin/{constants => modules/untappd}/hive_constants.dart (100%) create mode 100644 bin/modules/untappd/models/untappd_checkin.dart create mode 100644 bin/modules/untappd/untapped_module.dart diff --git a/bin/beer_bot.dart b/bin/beer_bot.dart index f7dacf4..02bc1b7 100644 --- a/bin/beer_bot.dart +++ b/bin/beer_bot.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:hive/hive.dart'; @@ -6,9 +5,9 @@ import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; import 'commands.dart'; -import 'constants/hive_constants.dart'; import 'modules/beer_agent/beer_agent_module.dart'; -import 'modules/untappd/untapped_service.dart'; +import 'modules/untappd/hive_constants.dart'; +import 'modules/untappd/untapped_module.dart'; String BOT_TOKEN = Platform.environment['DISCORD_TOKEN'] ?? ''; @@ -39,83 +38,5 @@ void main(List arguments) { // Initialize bot modules BeerAgentModule().init(bot); - - Timer.periodic(Duration(minutes: 12), (timer) => checkUntappd()); -} - -void checkUntappd() async { - var box = Hive.box(HiveConstants.untappdBox); - - Map listOfUsers = - await box.get(HiveConstants.untappdUserList, defaultValue: {}); - var latestCheckins = - await box.get(HiveConstants.untappdLatestUserCheckins, defaultValue: {}); - - var updateChannelId = await box.get(HiveConstants.untappdUpdateChannelId); - - if (updateChannelId == null) { - print('No channel available for updates!'); - return; - } - - if (listOfUsers.isEmpty) print('No users available to scrape!'); - - listOfUsers.forEach((userSnowflake, untappdUsername) async { - var latestCheckinDisk = latestCheckins[untappdUsername]; - try { - var latestCheckinUntappd = - await UntappdService.getLatestCheckin(untappdUsername); - - // If a new ID is available, post update! - if (latestCheckinUntappd != null && - latestCheckinDisk != latestCheckinUntappd.id) { - // Update latest saved checkin - latestCheckins.addAll({untappdUsername: latestCheckinUntappd.id}); - await box.put(HiveConstants.untappdLatestUserCheckins, latestCheckins); - - // Build update message with info from untappd checkin - var user = await bot.fetchUser(Snowflake(userSnowflake)); - var embedBuilder = EmbedBuilder(); - embedBuilder.title = '${user.username} is drinking beer!'; - embedBuilder.url = UntappdService.getCheckinUrl( - latestCheckinUntappd.id, untappdUsername); - embedBuilder.description = latestCheckinUntappd.title; - if (latestCheckinUntappd.comment.isNotEmpty) { - embedBuilder.addField( - field: - EmbedFieldBuilder('Comment', latestCheckinUntappd.comment)); - } - if (latestCheckinUntappd.rating.isNotEmpty) { - embedBuilder.addField( - field: EmbedFieldBuilder( - 'Rating', - _buildRatingEmoji( - double.parse(latestCheckinUntappd.rating)))); - } - if (latestCheckinUntappd.photoAddress != null) { - embedBuilder.imageUrl = latestCheckinUntappd.photoAddress; - } - - // Get channel used for untappd updates, previously set by discord admin. - var updateChannel = await bot - .fetchChannel(Snowflake(updateChannelId)) - .then((value) => (value as ITextChannel)); - - // Send update message - await updateChannel.sendMessage(MessageBuilder.embed(embedBuilder)); - } - // Sleep 5 seconds per user to avoid suspicious requests to untappd server - await Future.delayed(Duration(seconds: 5)); - } catch (e) { - print(e.toString()); - } - }); -} - -String _buildRatingEmoji(double rating) { - var ratingString = ''; - for (var i = 0; i < rating.toInt(); i++) { - ratingString += ':beer: '; - } - return '$ratingString ($rating)'; + UntappdModule().init(bot); } diff --git a/bin/commands.dart b/bin/commands.dart index 899881f..dcbbb7b 100644 --- a/bin/commands.dart +++ b/bin/commands.dart @@ -1,10 +1,8 @@ -import 'package:hive/hive.dart'; import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; -import 'constants/hive_constants.dart'; import 'modules/beer_agent/beer_agent_module.dart'; -import 'utils.dart'; +import 'modules/untappd/untapped_module.dart'; class Commands { static List getCommands() => [ @@ -17,96 +15,29 @@ class Commands { await _helpCommand(event); }), ...BeerAgentModule().commands, - SlashCommandBuilder( - 'untappd', - 'Let me know your untappd username so I can post automatic updates from your untappd account.', - [ - CommandOptionBuilder(CommandOptionType.string, 'username', - 'e.g. cornholio (kontot måste minst ha 1 incheckning)', - required: true), - ], - )..registerHandler((event) async { - await event.acknowledge(); - await _untappdCommand(event); - }), - SlashCommandBuilder( - 'setup', - 'Setup the bot to post untappd updates to the current channel.', - [], - requiredPermissions: PermissionsConstants.administrator, - )..registerHandler((event) async { - await event.acknowledge(); - await _setupUntappdServiceCommand(event); - }), + ...UntappdModule().commands, ]; static Future _helpCommand(ISlashCommandInteractionEvent ctx) async { var helpMessage = MessageBuilder() ..append(ctx.interaction.userAuthor!.mention) ..appendNewLine() - ..append('Did anyone say beer? This is what I can do for you:') + ..append(_mainHelpMessage) ..appendNewLine() ..appendNewLine() ..append(BeerAgentModule().helpMessage) ..appendNewLine() ..appendNewLine() - ..appendBold('/help') - ..appendNewLine() - ..append('Shows you this help message.') - ..appendNewLine() - ..appendNewLine() - ..appendBold('/untappd untappd_username') - ..appendNewLine() - ..append( - 'Let me know your untappd username so I can post automatic updates from your untappd account. e.g ') - ..appendItalics('/untappd cornholio') - ..appendNewLine() - ..appendNewLine() - ..appendBold('/setup') - ..appendNewLine() - ..append( - 'Setup the bot to post untappd updates to the current channel. Only admins can issue this command. Also, this is needed before any untappd updates can occur.'); + ..append(UntappdModule().helpMessage); await ctx.respond(helpMessage); } - static Future _untappdCommand(ISlashCommandInteractionEvent ctx) async { - var box = Hive.box(HiveConstants.untappdBox); - if (box.get(HiveConstants.untappdUpdateChannelId) == null) { - await ctx.respond(MessageBuilder.content( - ctx.interaction.userAuthor!.mention + - ' Whops, ask your admin to run setup first! :beers:')); - return; - } - if (ctx.args.length != 1) { - await ctx.respond(MessageBuilder.content( - ctx.interaction.userAuthor!.mention + - ' Are you drunk buddy? Your username is missing.')); - } - var discordUser = ctx.interaction.userAuthor!.id; - var untappdUsername = ctx.args.first.value; - - if (!await regUntappdUser(discordUser, untappdUsername)) { - await ctx.respond(MessageBuilder.content( - ctx.interaction.userAuthor!.mention + - ' Whops, something went sideways! :beers:')); - } - await ctx.respond(MessageBuilder.content( - ctx.interaction.userAuthor!.mention + - ' From now on I will post your updates from untappd! :beers:')); - } - - static Future _setupUntappdServiceCommand( - ISlashCommandInteractionEvent ctx) async { - if (ctx.interaction.memberAuthorPermissions?.administrator ?? false) { - var beerUpdateChannel = await ctx.interaction.channel.getOrDownload(); - - var box = Hive.box(HiveConstants.untappdBox); - await box.put(HiveConstants.untappdUpdateChannelId, - beerUpdateChannel.id.toString()); - - await beerUpdateChannel.sendMessage(MessageBuilder.content( - ' I will post untappd updates to this channel! Ask your users to register their username with /untappd followed by their untappd username.')); - } - } + static MessageBuilder get _mainHelpMessage => MessageBuilder() + ..append('Did anyone say beer? This is what I can do for you:') + ..appendNewLine() + ..appendNewLine() + ..appendBold('/help') + ..appendNewLine() + ..append('Shows you this help message.'); } diff --git a/bin/modules/beer_agent/beer_agent_module.dart b/bin/modules/beer_agent/beer_agent_module.dart index 5548ebf..0718cd1 100644 --- a/bin/modules/beer_agent/beer_agent_module.dart +++ b/bin/modules/beer_agent/beer_agent_module.dart @@ -200,7 +200,6 @@ class BeerAgentModule extends BotModule { await myFile.writeAsString(userSnowflake.toString(), mode: FileMode.append); } - /// Initializes the beer agent service. @override void init(INyxxWebsocket bot) { _bot = bot; @@ -212,7 +211,6 @@ class BeerAgentModule extends BotModule { _isInitialized = true; } - /// Returns a list of all slash commands for the beer agent module. @override List get commands => [ SlashCommandBuilder( @@ -253,7 +251,6 @@ class BeerAgentModule extends BotModule { }), ]; - /// Returns a help message for the beer agent module. @override MessageBuilder get helpMessage => MessageBuilder() ..appendBold('/oel') diff --git a/bin/modules/bot_module.dart b/bin/modules/bot_module.dart index 940f49d..651b216 100644 --- a/bin/modules/bot_module.dart +++ b/bin/modules/bot_module.dart @@ -2,7 +2,12 @@ import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; abstract class BotModule { + /// Initializes the module void init(INyxxWebsocket bot); + + /// Returns the list of commands for the module List get commands; + + /// Returns the help message for the module MessageBuilder get helpMessage; } diff --git a/bin/modules/untappd/commands.dart b/bin/modules/untappd/commands.dart new file mode 100644 index 0000000..1ea5c0e --- /dev/null +++ b/bin/modules/untappd/commands.dart @@ -0,0 +1,40 @@ +part of 'untapped_module.dart'; + +Future _untappdCommand(ISlashCommandInteractionEvent ctx) async { + var box = Hive.box(HiveConstants.untappdBox); + if (box.get(HiveConstants.untappdUpdateChannelId) == null) { + await ctx.respond(MessageBuilder.content( + ctx.interaction.userAuthor!.mention + + ' Whops, ask your admin to run setup first! :beers:')); + return; + } + if (ctx.args.length != 1) { + await ctx.respond(MessageBuilder.content( + ctx.interaction.userAuthor!.mention + + ' Are you drunk buddy? Your username is missing.')); + } + var discordUser = ctx.interaction.userAuthor!.id; + var untappdUsername = ctx.args.first.value; + + if (!await UntappdModule()._regUntappdUser(discordUser, untappdUsername)) { + await ctx.respond(MessageBuilder.content( + ctx.interaction.userAuthor!.mention + + ' Whops, something went sideways! :beers:')); + } + await ctx.respond(MessageBuilder.content(ctx.interaction.userAuthor!.mention + + ' From now on I will post your updates from untappd! :beers:')); +} + +Future _setupUntappdServiceCommand( + ISlashCommandInteractionEvent ctx) async { + if (ctx.interaction.memberAuthorPermissions?.administrator ?? false) { + var beerUpdateChannel = await ctx.interaction.channel.getOrDownload(); + + var box = Hive.box(HiveConstants.untappdBox); + await box.put( + HiveConstants.untappdUpdateChannelId, beerUpdateChannel.id.toString()); + + await beerUpdateChannel.sendMessage(MessageBuilder.content( + ' I will post untappd updates to this channel! Ask your users to register their username with /untappd followed by their untappd username.')); + } +} diff --git a/bin/constants/hive_constants.dart b/bin/modules/untappd/hive_constants.dart similarity index 100% rename from bin/constants/hive_constants.dart rename to bin/modules/untappd/hive_constants.dart diff --git a/bin/modules/untappd/models/untappd_checkin.dart b/bin/modules/untappd/models/untappd_checkin.dart new file mode 100644 index 0000000..d9b6ad0 --- /dev/null +++ b/bin/modules/untappd/models/untappd_checkin.dart @@ -0,0 +1,19 @@ +class UntappdCheckin { + const UntappdCheckin({ + required this.id, + required this.title, + required this.rating, + required this.comment, + this.photoAddress, + }); + final String id; + final String title; + final String rating; + final String comment; + final String? photoAddress; + + @override + String toString() { + return 'title: $title\nrating: $rating\ncomment: $comment\nphoto url: $photoAddress\n'; + } +} diff --git a/bin/modules/untappd/untapped_module.dart b/bin/modules/untappd/untapped_module.dart new file mode 100644 index 0000000..d8b5298 --- /dev/null +++ b/bin/modules/untappd/untapped_module.dart @@ -0,0 +1,243 @@ +import 'dart:async'; + +import 'package:hive/hive.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:web_scraper/web_scraper.dart'; + +import '../bot_module.dart'; +import 'hive_constants.dart'; +import 'models/untappd_checkin.dart'; + +part 'commands.dart'; + +class UntappdModule extends BotModule { + bool _isInitialized = false; + + late INyxxWebsocket _bot; + + /// Fetches and updates untappd checkins for all users + void _checkUntappd() async { + if (!_isInitialized) { + print('Untappd module not initialized!'); + throw Exception('Untappd module not initialized!'); + } + var box = Hive.box(HiveConstants.untappdBox); + + Map listOfUsers = + await box.get(HiveConstants.untappdUserList, defaultValue: {}); + var latestCheckins = await box + .get(HiveConstants.untappdLatestUserCheckins, defaultValue: {}); + + var updateChannelId = await box.get(HiveConstants.untappdUpdateChannelId); + + if (updateChannelId == null) { + print('No channel available for updates!'); + return; + } + + if (listOfUsers.isEmpty) print('No users available to scrape!'); + + listOfUsers.forEach((userSnowflake, untappdUsername) async { + var latestCheckinDisk = latestCheckins[untappdUsername]; + try { + var latestCheckinUntappd = await _getLatestCheckin(untappdUsername); + + // If a new ID is available, post update! + if (latestCheckinUntappd != null && + latestCheckinDisk != latestCheckinUntappd.id) { + // Update latest saved checkin + latestCheckins.addAll({untappdUsername: latestCheckinUntappd.id}); + await box.put( + HiveConstants.untappdLatestUserCheckins, latestCheckins); + + // Build update message with info from untappd checkin + var user = await _bot.fetchUser(Snowflake(userSnowflake)); + var embedBuilder = EmbedBuilder(); + embedBuilder.title = '${user.username} is drinking beer!'; + embedBuilder.url = + _getCheckinUrl(latestCheckinUntappd.id, untappdUsername); + embedBuilder.description = latestCheckinUntappd.title; + if (latestCheckinUntappd.comment.isNotEmpty) { + embedBuilder.addField( + field: + EmbedFieldBuilder('Comment', latestCheckinUntappd.comment)); + } + if (latestCheckinUntappd.rating.isNotEmpty) { + embedBuilder.addField( + field: EmbedFieldBuilder( + 'Rating', + _buildRatingEmoji( + double.parse(latestCheckinUntappd.rating)))); + } + if (latestCheckinUntappd.photoAddress != null) { + embedBuilder.imageUrl = latestCheckinUntappd.photoAddress; + } + + // Get channel used for untappd updates, previously set by discord admin. + var updateChannel = await _bot + .fetchChannel(Snowflake(updateChannelId)) + .then((value) => (value as ITextChannel)); + + // Send update message + await updateChannel.sendMessage(MessageBuilder.embed(embedBuilder)); + } + // Sleep 5 seconds per user to avoid suspicious requests to untappd server + await Future.delayed(Duration(seconds: 5)); + } catch (e) { + print(e.toString()); + } + }); + } + + /// Builds the rating emoji string + String _buildRatingEmoji(double rating) { + var ratingString = ''; + for (var i = 0; i < rating.toInt(); i++) { + ratingString += ':beer: '; + } + return '$ratingString ($rating)'; + } + + /// Register untappd username for given user snowflake + Future _regUntappdUser( + Snowflake userSnowflake, String untappdUsername) async { + try { + var box = Hive.box(HiveConstants.untappdBox); + + if (!await _isValidUsername(untappdUsername)) { + print('No checkins available for user, ignoring add.'); + return false; + } + + var currentList = + box.get(HiveConstants.untappdUserList, defaultValue: {}); + currentList.addAll({userSnowflake.toString(): untappdUsername}); + await box.put(HiveConstants.untappdUserList, currentList); + print('Saved ${currentList.toString()} to Hive box!'); + return true; + } catch (e) { + return false; + } + } + + /// Check validity of the username provided + /// + /// Will return **true** if given username has at least one checkin on Untappd. + Future _isValidUsername(String untappdUsername) async { + final webScraper = WebScraper('https://untappd.com'); + if (await webScraper.loadWebPage('/user/$untappdUsername')) { + final checkins = webScraper.getElementAttribute( + 'div#main-stream > *', 'data-checkin-id'); + + if (checkins.isEmpty) { + return false; + } + return true; + } else { + throw 'Error during fetching of Untappd data'; + } + } + + /// Get latest checkin for given untapped username + Future _getLatestCheckin(String untappdUsername) async { + final webScraper = WebScraper('https://untappd.com'); + if (await webScraper.loadWebPage('/user/$untappdUsername')) { + final checkins = webScraper.getElementAttribute( + 'div#main-stream > *', 'data-checkin-id'); + + if (checkins.isEmpty) { + throw 'No checkins are available for $untappdUsername'; + } + + var latestCheckin = checkins.first!; + + var baseCheckinAddress = + 'div#main-stream > #checkin_$latestCheckin > div.checkin > div.top'; + + final checkinTitleElement = + webScraper.getElementTitle('$baseCheckinAddress > p.text'); + final checkinTitle = + checkinTitleElement.isEmpty ? '' : checkinTitleElement.first.trim(); + + final checkinRatingElement = webScraper.getElement( + '$baseCheckinAddress > div.checkin-comment > div.rating-serving > div.caps ', + ['data-rating']); + final String checkinRating = checkinRatingElement.isEmpty + ? '0' + : checkinRatingElement.first['attributes']['data-rating']; + + final checkinCommentElement = webScraper.getElementTitle( + '$baseCheckinAddress > div.checkin-comment > p.comment-text'); + final checkinComment = checkinCommentElement.isEmpty + ? '' + : checkinCommentElement.first.trim(); + + final photo = webScraper.getElementAttribute( + '$baseCheckinAddress > p.photo > a > img', 'data-original'); + final checkinPhotoAddress = photo.isNotEmpty ? photo.first : null; + + return UntappdCheckin( + id: latestCheckin, + title: checkinTitle, + rating: checkinRating, + comment: checkinComment, + photoAddress: checkinPhotoAddress); + } + return null; + } + + /// Get untappd detailed checkin URL + String _getCheckinUrl(String checkinId, String username) => + 'https://untappd.com/user/$username/checkin/$checkinId'; + + @override + void init(INyxxWebsocket bot, + {Duration updateInterval = const Duration(minutes: 12)}) { + _bot = bot; + + // Start timer to check for untappd updates + Timer.periodic(updateInterval, (timer) => _checkUntappd()); + + // Set module as initialized + _isInitialized = true; + } + + @override + List get commands => [ + SlashCommandBuilder( + 'untappd', + 'Let me know your untappd username so I can post automatic updates from your untappd account.', + [ + CommandOptionBuilder(CommandOptionType.string, 'username', + 'e.g. cornholio (kontot måste minst ha 1 incheckning)', + required: true), + ], + )..registerHandler((event) async { + await event.acknowledge(); + await _untappdCommand(event); + }), + SlashCommandBuilder( + 'setup', + 'Setup the bot to post untappd updates to the current channel.', + [], + requiredPermissions: PermissionsConstants.administrator, + )..registerHandler((event) async { + await event.acknowledge(); + await _setupUntappdServiceCommand(event); + }), + ]; + + @override + MessageBuilder get helpMessage => MessageBuilder() + ..appendBold('/untappd') + ..appendNewLine() + ..append( + 'Registers your untappd username so I can post automatic updates based on your untappd checkins.') + ..appendNewLine() + ..appendNewLine() + ..appendBold('/setup') + ..appendNewLine() + ..append( + 'Setup the bot to post untappd updates to the current channel. (Only admins can issue this command.)'); +} diff --git a/bin/utils.dart b/bin/utils.dart index 65bb1f9..f9adea2 100644 --- a/bin/utils.dart +++ b/bin/utils.dart @@ -1,11 +1,6 @@ import 'dart:convert'; -import 'package:hive/hive.dart'; import 'package:http/http.dart' as http; -import 'package:nyxx/nyxx.dart'; - -import 'constants/hive_constants.dart'; -import 'modules/untappd/untapped_service.dart'; Future> httpGetRequest(String getURL) async { final response = await http.get(Uri.parse(getURL)); @@ -17,30 +12,3 @@ Future> httpGetRequest(String getURL) async { throw Exception('Error during GET request!'); } } - -Future isUserUntappdRegistered(Snowflake userSnowflake) async { - var box = Hive.box(HiveConstants.untappdBox); - Map userList = - box.get(HiveConstants.untappdUserList, defaultValue: {}); - return userList.keys.contains(userSnowflake); -} - -Future regUntappdUser( - Snowflake userSnowflake, String untappdUsername) async { - try { - var box = Hive.box(HiveConstants.untappdBox); - - if (!await UntappdService.isValidUsername(untappdUsername)) { - print('No checkins available for user, ignoring add.'); - return false; - } - - var currentList = box.get(HiveConstants.untappdUserList, defaultValue: {}); - currentList.addAll({userSnowflake.toString(): untappdUsername}); - await box.put(HiveConstants.untappdUserList, currentList); - print('Saved ${currentList.toString()} to Hive box!'); - return true; - } catch (e) { - return false; - } -} From d724f7e56a5693e158590bb1caac7d1331f365a5 Mon Sep 17 00:00:00 2001 From: oelburk Date: Wed, 26 Jul 2023 01:04:04 +0200 Subject: [PATCH 3/7] Clean up hive references --- bin/beer_bot.dart | 5 ----- bin/modules/untappd/untapped_module.dart | 4 ++++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/bin/beer_bot.dart b/bin/beer_bot.dart index 02bc1b7..cb5886b 100644 --- a/bin/beer_bot.dart +++ b/bin/beer_bot.dart @@ -1,12 +1,10 @@ import 'dart:io'; -import 'package:hive/hive.dart'; import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; import 'commands.dart'; import 'modules/beer_agent/beer_agent_module.dart'; -import 'modules/untappd/hive_constants.dart'; import 'modules/untappd/untapped_module.dart'; String BOT_TOKEN = Platform.environment['DISCORD_TOKEN'] ?? ''; @@ -14,9 +12,6 @@ String BOT_TOKEN = Platform.environment['DISCORD_TOKEN'] ?? ''; late final INyxxWebsocket bot; void main(List arguments) { - Hive.init('/data'); - Hive.openBox(HiveConstants.untappdBox); - bot = NyxxFactory.createNyxxWebsocket(BOT_TOKEN, GatewayIntents.allUnprivileged) ..registerPlugin(Logging()) diff --git a/bin/modules/untappd/untapped_module.dart b/bin/modules/untappd/untapped_module.dart index d8b5298..d9c95f0 100644 --- a/bin/modules/untappd/untapped_module.dart +++ b/bin/modules/untappd/untapped_module.dart @@ -194,6 +194,10 @@ class UntappdModule extends BotModule { @override void init(INyxxWebsocket bot, {Duration updateInterval = const Duration(minutes: 12)}) { + // Set up Hive for local data storage + Hive.init('/data'); + Hive.openBox(HiveConstants.untappdBox); + _bot = bot; // Start timer to check for untappd updates From 2fe75a63c90c15e550b09887d72deec882f40944 Mon Sep 17 00:00:00 2001 From: oelburk Date: Wed, 26 Jul 2023 01:09:56 +0200 Subject: [PATCH 4/7] Unload beer agent module --- bin/beer_bot.dart | 2 -- bin/modules/beer_agent/beer_agent_module.dart | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/beer_bot.dart b/bin/beer_bot.dart index cb5886b..422b94c 100644 --- a/bin/beer_bot.dart +++ b/bin/beer_bot.dart @@ -4,7 +4,6 @@ import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; import 'commands.dart'; -import 'modules/beer_agent/beer_agent_module.dart'; import 'modules/untappd/untapped_module.dart'; String BOT_TOKEN = Platform.environment['DISCORD_TOKEN'] ?? ''; @@ -32,6 +31,5 @@ void main(List arguments) { }); // Initialize bot modules - BeerAgentModule().init(bot); UntappdModule().init(bot); } diff --git a/bin/modules/beer_agent/beer_agent_module.dart b/bin/modules/beer_agent/beer_agent_module.dart index 0718cd1..ca3b07d 100644 --- a/bin/modules/beer_agent/beer_agent_module.dart +++ b/bin/modules/beer_agent/beer_agent_module.dart @@ -22,6 +22,10 @@ class BeerAgentModule extends BotModule { late INyxxWebsocket _bot; static final BeerAgentModule _singleton = BeerAgentModule._internal(); + @deprecated + + /// Systembevakningsagenten.se is no longer available due to legal issues. + /// See https://systembevakningsagenten.se/ for more information. factory BeerAgentModule() { return _singleton; } From 38ab0554cc107283e2479ed8cd919e91050ffed2 Mon Sep 17 00:00:00 2001 From: oelburk Date: Wed, 26 Jul 2023 01:24:21 +0200 Subject: [PATCH 5/7] Clean up missed refs to beer agent, add init checks --- bin/commands.dart | 5 -- bin/modules/beer_agent/beer_agent_module.dart | 82 ++++++++++--------- bin/modules/untappd/untapped_module.dart | 52 ++++++------ 3 files changed, 71 insertions(+), 68 deletions(-) diff --git a/bin/commands.dart b/bin/commands.dart index dcbbb7b..d6a5284 100644 --- a/bin/commands.dart +++ b/bin/commands.dart @@ -1,7 +1,6 @@ import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; -import 'modules/beer_agent/beer_agent_module.dart'; import 'modules/untappd/untapped_module.dart'; class Commands { @@ -14,7 +13,6 @@ class Commands { await event.acknowledge(); await _helpCommand(event); }), - ...BeerAgentModule().commands, ...UntappdModule().commands, ]; @@ -25,9 +23,6 @@ class Commands { ..append(_mainHelpMessage) ..appendNewLine() ..appendNewLine() - ..append(BeerAgentModule().helpMessage) - ..appendNewLine() - ..appendNewLine() ..append(UntappdModule().helpMessage); await ctx.respond(helpMessage); diff --git a/bin/modules/beer_agent/beer_agent_module.dart b/bin/modules/beer_agent/beer_agent_module.dart index ca3b07d..0ca816d 100644 --- a/bin/modules/beer_agent/beer_agent_module.dart +++ b/bin/modules/beer_agent/beer_agent_module.dart @@ -216,47 +216,51 @@ class BeerAgentModule extends BotModule { } @override - List get commands => [ - SlashCommandBuilder( - 'oel', - 'Show the latest beer releases.', - [], - )..registerHandler((event) async { - await event.acknowledge(); - await _oelCommand(event); - }), - SlashCommandBuilder( - 'subscribe', - 'Subscribe to beer release reminders.', - [], - )..registerHandler((event) async { - await event.acknowledge(); - await _regCommand(event); - }), - SlashCommandBuilder( - 'stop', - 'Unsubscribe to beer release reminders.', - [], - )..registerHandler((event) async { - await event.acknowledge(); - await _stopCommand(event); - }), - SlashCommandBuilder( - 'release', - 'Detailed info about a specific beer release e.g. /release 2022-07-15', - [ - CommandOptionBuilder( - CommandOptionType.string, 'datum', 'YYYY-MM-dd', - required: true), - ], - )..registerHandler((event) async { - await event.acknowledge(); - await _releaseCommand(event); - }), - ]; + List get commands => !_isInitialized + ? throw Exception('Beer agent module not initialized!') + : [ + SlashCommandBuilder( + 'oel', + 'Show the latest beer releases.', + [], + )..registerHandler((event) async { + await event.acknowledge(); + await _oelCommand(event); + }), + SlashCommandBuilder( + 'subscribe', + 'Subscribe to beer release reminders.', + [], + )..registerHandler((event) async { + await event.acknowledge(); + await _regCommand(event); + }), + SlashCommandBuilder( + 'stop', + 'Unsubscribe to beer release reminders.', + [], + )..registerHandler((event) async { + await event.acknowledge(); + await _stopCommand(event); + }), + SlashCommandBuilder( + 'release', + 'Detailed info about a specific beer release e.g. /release 2022-07-15', + [ + CommandOptionBuilder( + CommandOptionType.string, 'datum', 'YYYY-MM-dd', + required: true), + ], + )..registerHandler((event) async { + await event.acknowledge(); + await _releaseCommand(event); + }), + ]; @override - MessageBuilder get helpMessage => MessageBuilder() + MessageBuilder get helpMessage => !_isInitialized + ? throw Exception('Beer agent not initialized!') + : MessageBuilder() ..appendBold('/oel') ..appendNewLine() ..append('Lists all known beer releases.') diff --git a/bin/modules/untappd/untapped_module.dart b/bin/modules/untappd/untapped_module.dart index d9c95f0..3cf40c7 100644 --- a/bin/modules/untappd/untapped_module.dart +++ b/bin/modules/untappd/untapped_module.dart @@ -208,32 +208,36 @@ class UntappdModule extends BotModule { } @override - List get commands => [ - SlashCommandBuilder( - 'untappd', - 'Let me know your untappd username so I can post automatic updates from your untappd account.', - [ - CommandOptionBuilder(CommandOptionType.string, 'username', - 'e.g. cornholio (kontot måste minst ha 1 incheckning)', - required: true), - ], - )..registerHandler((event) async { - await event.acknowledge(); - await _untappdCommand(event); - }), - SlashCommandBuilder( - 'setup', - 'Setup the bot to post untappd updates to the current channel.', - [], - requiredPermissions: PermissionsConstants.administrator, - )..registerHandler((event) async { - await event.acknowledge(); - await _setupUntappdServiceCommand(event); - }), - ]; + List get commands => !_isInitialized + ? throw Exception('Untappd module not initialized!') + : [ + SlashCommandBuilder( + 'untappd', + 'Let me know your untappd username so I can post automatic updates from your untappd account.', + [ + CommandOptionBuilder(CommandOptionType.string, 'username', + 'e.g. cornholio (kontot måste minst ha 1 incheckning)', + required: true), + ], + )..registerHandler((event) async { + await event.acknowledge(); + await _untappdCommand(event); + }), + SlashCommandBuilder( + 'setup', + 'Setup the bot to post untappd updates to the current channel.', + [], + requiredPermissions: PermissionsConstants.administrator, + )..registerHandler((event) async { + await event.acknowledge(); + await _setupUntappdServiceCommand(event); + }), + ]; @override - MessageBuilder get helpMessage => MessageBuilder() + MessageBuilder get helpMessage => !_isInitialized + ? throw Exception('Untappd module not initialized!') + : MessageBuilder() ..appendBold('/untappd') ..appendNewLine() ..append( From 8be7ed332e5cf158cdf1125a4fc765b0bbf80fdc Mon Sep 17 00:00:00 2001 From: oelburk Date: Sun, 30 Jul 2023 00:49:19 +0200 Subject: [PATCH 6/7] Make untappd module a singleton --- bin/modules/untappd/untapped_module.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bin/modules/untappd/untapped_module.dart b/bin/modules/untappd/untapped_module.dart index 3cf40c7..9449f78 100644 --- a/bin/modules/untappd/untapped_module.dart +++ b/bin/modules/untappd/untapped_module.dart @@ -12,10 +12,17 @@ import 'models/untappd_checkin.dart'; part 'commands.dart'; class UntappdModule extends BotModule { + static final UntappdModule _singleton = UntappdModule._internal(); + bool _isInitialized = false; late INyxxWebsocket _bot; + factory UntappdModule() { + return _singleton; + } + UntappdModule._internal(); + /// Fetches and updates untappd checkins for all users void _checkUntappd() async { if (!_isInitialized) { From d11a619b35f921c81cbed17dc4e79a0a4053c6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfred=20T=C3=A5ng?= Date: Fri, 22 Mar 2024 12:11:44 +0100 Subject: [PATCH 7/7] remove deprecated in favour for analyzer --- bin/modules/beer_agent/beer_agent_module.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/modules/beer_agent/beer_agent_module.dart b/bin/modules/beer_agent/beer_agent_module.dart index 0ca816d..bf0c5da 100644 --- a/bin/modules/beer_agent/beer_agent_module.dart +++ b/bin/modules/beer_agent/beer_agent_module.dart @@ -22,7 +22,6 @@ class BeerAgentModule extends BotModule { late INyxxWebsocket _bot; static final BeerAgentModule _singleton = BeerAgentModule._internal(); - @deprecated /// Systembevakningsagenten.se is no longer available due to legal issues. /// See https://systembevakningsagenten.se/ for more information.