1
Fork 0
mirror of https://gitlab.com/Kwoth/nadekobot.git synced 2024-10-02 20:13:13 +00:00

Merge branch 'v5' into ticket

This commit is contained in:
Kwoth 2024-07-12 12:46:35 +00:00
commit 8e2700c613
15 changed files with 297 additions and 81 deletions

View file

@ -2,6 +2,22 @@
Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
## [5.1.3] - 06.07.2024
### Added
- Added `.quran` command, which will show the provided ayah in english and arabic, including recitation by Alafasy
### Changed
- Replying to the bot's message in the channel where chatterbot is enabled will also trigger the ai response, as if you pinged the bot. This only works for chatterbot, but not for nadeko ai command prompts
### Fixed
- Fixed `.stickeradd` it now properly supports 300x300 image uploads.
- Bot should now trim the invalid characters from chatterbot usernames to avoid openai errors
- Fixed prompt triggering chatterbot responses twice
## [5.1.2] - 29.06.2024
### Fixed

View file

@ -73,6 +73,27 @@ public partial class Gambling
await Response().Error(strs.cant_dm).SendAsync();
}
}
[Cmd]
[OwnerOnly]
public async Task BankBalance([Leftover] IUser user)
{
var bal = await _bank.GetBalanceAsync(user.Id);
var eb = _sender.CreateEmbed()
.WithOkColor()
.WithDescription(GetText(strs.bank_balance_other(user.ToString(), N(bal))));
try
{
await Response().User(ctx.User).Embed(eb).SendAsync();
await ctx.OkAsync();
}
catch
{
await Response().Error(strs.cant_dm).SendAsync();
}
}
private async Task BankTakeInternalAsync(long amount, ulong userId)
{

View file

@ -96,6 +96,8 @@ public class ChatterBotService : IExecOnMessage
message = msg.Content[normalMention.Length..].Trim();
else if (msg.Content.StartsWith(nickMention, StringComparison.InvariantCulture))
message = msg.Content[nickMention.Length..].Trim();
else if (msg.ReferencedMessage?.Author.Id == nadekoId)
message = msg.Content;
else
return null;

View file

@ -3,10 +3,12 @@ using Newtonsoft.Json;
using OneOf.Types;
using System.Net.Http.Json;
using SharpToken;
using System.CodeDom;
using System.Text.RegularExpressions;
namespace NadekoBot.Modules.Games.Common.ChatterBot;
public class OfficialGptSession : IChatterBotSession
public partial class OfficialGptSession : IChatterBotSession
{
private string Uri
=> $"https://api.openai.com/v1/chat/completions";
@ -45,7 +47,7 @@ public class OfficialGptSession : IChatterBotSession
_maxHistory = chatHistory;
_maxTokens = maxTokens;
_minTokens = minTokens;
_nadekoUsername = nadekoUsername;
_nadekoUsername = UsernameCleaner().Replace(nadekoUsername, "");
_encoding = GptEncoding.GetEncodingForModel(_model);
messages.Add(new()
{
@ -55,14 +57,21 @@ public class OfficialGptSession : IChatterBotSession
});
}
[GeneratedRegex("[^a-zA-Z0-9_-]")]
private static partial Regex UsernameCleaner();
public async Task<OneOf.OneOf<ThinkResult, Error<string>>> Think(string input, string username)
{
username = UsernameCleaner().Replace(username, "");
messages.Add(new()
{
Role = "user",
Content = input,
Name = username
});
while (messages.Count > _maxHistory + 2)
{
messages.RemoveAt(1);

View file

@ -16,12 +16,12 @@ public partial class Searches
_stocksService = stocksService;
_stockDrawingService = stockDrawingService;
}
[Cmd]
public async Task Stock([Leftover]string query)
public async Task Stock([Leftover] string query)
{
using var typing = ctx.Channel.EnterTypingState();
var stock = await _stocksService.GetStockDataAsync(query);
if (stock is null)
@ -36,9 +36,9 @@ public partial class Searches
var symbol = symbols.First();
var promptEmbed = _sender.CreateEmbed()
.WithDescription(symbol.Description)
.WithTitle(GetText(strs.did_you_mean(symbol.Symbol)));
.WithDescription(symbol.Description)
.WithTitle(GetText(strs.did_you_mean(symbol.Symbol)));
if (!await PromptUserConfirmAsync(promptEmbed))
return;
@ -54,7 +54,7 @@ public partial class Searches
var candles = await _stocksService.GetCandleDataAsync(query);
var stockImageTask = _stockDrawingService.GenerateCombinedChartAsync(candles);
var localCulture = (CultureInfo)Culture.Clone();
localCulture.NumberFormat.CurrencySymbol = "$";
@ -64,34 +64,34 @@ public partial class Searches
var change = (stock.Price - stock.Close).ToString("N2", Culture);
var changePercent = (1 - (stock.Close / stock.Price)).ToString("P1", Culture);
var sign50 = stock.Change50d >= 0
? "\\🔼"
: "\\🔻";
var change50 = (stock.Change50d).ToString("P1", Culture);
var sign200 = stock.Change200d >= 0
? "\\🔼"
: "\\🔻";
var change200 = (stock.Change200d).ToString("P1", Culture);
var price = stock.Price.ToString("C2", localCulture);
var eb = _sender.CreateEmbed()
.WithOkColor()
.WithAuthor(stock.Symbol)
.WithUrl($"https://www.tradingview.com/chart/?symbol={stock.Symbol}")
.WithTitle(stock.Name)
.AddField(GetText(strs.price), $"{sign} **{price}**", true)
.AddField(GetText(strs.market_cap), stock.MarketCap, true)
.AddField(GetText(strs.volume_24h), stock.DailyVolume.ToString("C0", localCulture), true)
.AddField("Change", $"{change} ({changePercent})", true)
// .AddField("Change 50d", $"{sign50}{change50}", true)
// .AddField("Change 200d", $"{sign200}{change200}", true)
.WithFooter(stock.Exchange);
.WithOkColor()
.WithAuthor(stock.Symbol)
.WithUrl($"https://www.tradingview.com/chart/?symbol={stock.Symbol}")
.WithTitle(stock.Name)
.AddField(GetText(strs.price), $"{sign} **{price}**", true)
.AddField(GetText(strs.market_cap), stock.MarketCap, true)
.AddField(GetText(strs.volume_24h), stock.DailyVolume.ToString("C0", localCulture), true)
.AddField("Change", $"{change} ({changePercent})", true)
// .AddField("Change 50d", $"{sign50}{change50}", true)
// .AddField("Change 200d", $"{sign200}{change200}", true)
.WithFooter(stock.Exchange);
var message = await Response().Embed(eb).SendAsync();
await using var imageData = await stockImageTask;
if (imageData is null)
@ -105,15 +105,12 @@ public partial class Searches
await message.ModifyAsync(mp =>
{
mp.Attachments =
new(new[]
{
attachment
});
new(new[] { attachment });
mp.Embed = eb.WithImageUrl($"attachment://{fileName}").Build();
});
}
[Cmd]
public async Task Crypto(string name)
@ -128,9 +125,9 @@ public partial class Searches
if (nearest is not null)
{
var embed = _sender.CreateEmbed()
.WithTitle(GetText(strs.crypto_not_found))
.WithDescription(
GetText(strs.did_you_mean(Format.Bold($"{nearest.Name} ({nearest.Symbol})"))));
.WithTitle(GetText(strs.crypto_not_found))
.WithDescription(
GetText(strs.did_you_mean(Format.Bold($"{nearest.Name} ({nearest.Symbol})"))));
if (await PromptUserConfirmAsync(embed))
crypto = nearest;
@ -146,7 +143,7 @@ public partial class Searches
var localCulture = (CultureInfo)Culture.Clone();
localCulture.NumberFormat.CurrencySymbol = "$";
var sevenDay = (usd.PercentChange7d / 100).ToString("P2", localCulture);
var lastDay = (usd.PercentChange24h / 100).ToString("P2", localCulture);
var price = usd.Price < 0.01
@ -159,28 +156,29 @@ public partial class Searches
await using var sparkline = await _service.GetSparklineAsync(crypto.Id, usd.PercentChange7d >= 0);
var fileName = $"{crypto.Slug}_7d.png";
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithAuthor($"#{crypto.CmcRank}")
.WithTitle($"{crypto.Name} ({crypto.Symbol})")
.WithUrl($"https://coinmarketcap.com/currencies/{crypto.Slug}/")
.WithThumbnailUrl($"https://s3.coinmarketcap.com/static/img/coins/128x128/{crypto.Id}.png")
.AddField(GetText(strs.market_cap), marketCap, true)
.AddField(GetText(strs.price), price, true)
.AddField(GetText(strs.volume_24h), volume, true)
.AddField(GetText(strs.change_7d_24h), $"{sevenDay} / {lastDay}", true)
.AddField(GetText(strs.market_cap_dominance), dominance, true)
.WithImageUrl($"attachment://{fileName}");
.WithOkColor()
.WithAuthor($"#{crypto.CmcRank}")
.WithTitle($"{crypto.Name} ({crypto.Symbol})")
.WithUrl($"https://coinmarketcap.com/currencies/{crypto.Slug}/")
.WithThumbnailUrl(
$"https://s3.coinmarketcap.com/static/img/coins/128x128/{crypto.Id}.png")
.AddField(GetText(strs.market_cap), marketCap, true)
.AddField(GetText(strs.price), price, true)
.AddField(GetText(strs.volume_24h), volume, true)
.AddField(GetText(strs.change_7d_24h), $"{sevenDay} / {lastDay}", true)
.AddField(GetText(strs.market_cap_dominance), dominance, true)
.WithImageUrl($"attachment://{fileName}");
if (crypto.CirculatingSupply is double cs)
{
var csStr = cs.ToString("N0", localCulture);
if (crypto.MaxSupply is double ms)
{
var perc = (cs / ms).ToString("P1", localCulture);
toSend.AddField(GetText(strs.circulating_supply), $"{csStr} ({perc})", true);
}
else
@ -192,5 +190,54 @@ public partial class Searches
await ctx.Channel.SendFileAsync(sparkline, fileName, embed: toSend.Build());
}
[Cmd]
public async Task Coins(int page = 1)
{
if (--page < 0)
return;
if (page > 25)
page = 25;
await Response()
.Paginated()
.PageItems(async (page) =>
{
var coins = await _service.GetTopCoins(page);
return coins;
})
.PageSize(10)
.Page((items, _) =>
{
var embed = _sender.CreateEmbed()
.WithOkColor();
if (items.Count > 0)
{
foreach (var coin in items)
{
embed.AddField($"#{coin.MarketCapRank} {coin.Symbol} - {coin.Name}",
$"""
`Price:` {GetArrowEmoji(coin.PercentChange24h)} {coin.CurrentPrice.ToShortString()}$ ({GetSign(coin.PercentChange24h)}{Math.Round(coin.PercentChange24h, 2)}%)
`MarketCap:` {coin.MarketCap.ToShortString()}$
`Supply:` {(coin.CirculatingSupply?.ToShortString() ?? "?")} / {(coin.TotalSupply?.ToShortString() ?? "?")}
""",
inline: false);
}
}
return embed;
})
.CurrentPage(page)
.AddFooter(false)
.SendAsync();
}
private static string GetArrowEmoji(decimal value)
=> value > 0 ? "▲" : "▼";
private static string GetSign(decimal value)
=> value >= 0 ? "+" : "-";
}
}

View file

@ -4,8 +4,10 @@ using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Xml;
using Color = SixLabors.ImageSharp.Color;
using StringExtensions = NadekoBot.Extensions.StringExtensions;
@ -212,4 +214,55 @@ public class CryptoService : INService
var points = GetSparklinePointsFromSvgText(str);
return points;
}
private static TypedKey<IReadOnlyCollection<GeckoCoinsResult>> GetTopCoinsKey()
=> new($"crypto:top_coins");
public async Task<IReadOnlyCollection<GeckoCoinsResult>?> GetTopCoins(int page)
{
if (page >= 25)
page = 24;
using var http = _httpFactory.CreateClient();
http.AddFakeHeaders();
var result = await _cache.GetOrAddAsync<IReadOnlyCollection<GeckoCoinsResult>>(GetTopCoinsKey(),
async () => await http.GetFromJsonAsync<List<GeckoCoinsResult>>(
"https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250")
?? [],
expiry: TimeSpan.FromHours(1));
return result!.Skip(page * 10).Take(10).ToList();
}
}
public sealed class GeckoCoinsResult
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("symbol")]
public required string Symbol { get; init; }
[JsonPropertyName("current_price")]
public required decimal CurrentPrice { get; init; }
[JsonPropertyName("price_change_percentage_24h")]
public required decimal PercentChange24h { get; init; }
[JsonPropertyName("market_cap")]
public required decimal MarketCap { get; init; }
[JsonPropertyName("circulating_supply")]
public required decimal? CirculatingSupply { get; init; }
[JsonPropertyName("total_supply")]
public required decimal? TotalSupply { get; init; }
[JsonPropertyName("market_cap_rank")]
public required int MarketCapRank { get; init; }
}

View file

@ -123,6 +123,8 @@ public partial class Xp
})
.ToList();
var rank = await _service.GetClubRankAsync(club.Id);
await Response()
.Paginated()
.Items(allUsers)
@ -135,6 +137,7 @@ public partial class Xp
.WithDescription(GetText(strs.level_x(lvl.Level + $" ({club.Xp} xp)")))
.AddField(GetText(strs.desc),
string.IsNullOrWhiteSpace(club.Description) ? "-" : club.Description)
.AddField(GetText(strs.rank), $"#{rank}", true)
.AddField(GetText(strs.owner), club.Owner.ToString(), true)
// .AddField(GetText(strs.level_req), club.MinimumLevelReq.ToString(), true)
.AddField(GetText(strs.members),

View file

@ -23,22 +23,22 @@ public class ClubService : INService, IClubService
{
if (!CheckClubName(clubName))
return ClubCreateResult.NameTooLong;
//must be lvl 5 and must not be in a club already
await using var uow = _db.GetDbContext();
var du = uow.GetOrCreateUser(user);
var xp = new LevelStats(du.TotalXp);
if (xp.Level < 5)
if (xp.Level < 5)
return ClubCreateResult.InsufficientLevel;
if (du.ClubId is not null)
return ClubCreateResult.AlreadyInAClub;
if (await uow.Set<ClubInfo>().AnyAsyncEF(x => x.Name == clubName))
return ClubCreateResult.NameTaken;
du.IsClubAdmin = true;
du.Club = new()
{
@ -53,7 +53,7 @@ public class ClubService : INService, IClubService
return ClubCreateResult.Success;
}
public OneOf<ClubInfo, ClubTransferError> TransferClub(IUser from, IUser newOwner)
{
using var uow = _db.GetDbContext();
@ -62,7 +62,7 @@ public class ClubService : INService, IClubService
if (club is null || club.Owner.UserId != from.Id)
return ClubTransferError.NotOwner;
if (!club.Members.Contains(newOwnerUser))
return ClubTransferError.TargetNotMember;
@ -72,22 +72,22 @@ public class ClubService : INService, IClubService
uow.SaveChanges();
return club;
}
public async Task<ToggleAdminResult> ToggleAdminAsync(IUser owner, IUser toAdmin)
{
if (owner.Id == toAdmin.Id)
return ToggleAdminResult.CantTargetThyself;
await using var uow = _db.GetDbContext();
var club = uow.Set<ClubInfo>().GetByOwner(owner.Id);
var adminUser = uow.GetOrCreateUser(toAdmin);
if (club is null)
return ToggleAdminResult.NotOwner;
if(!club.Members.Contains(adminUser))
if (!club.Members.Contains(adminUser))
return ToggleAdminResult.TargetNotMember;
var newState = adminUser.IsClubAdmin = !adminUser.IsClubAdmin;
await uow.SaveChangesAsync();
return newState ? ToggleAdminResult.AddedAdmin : ToggleAdminResult.RemovedAdmin;
@ -99,17 +99,17 @@ public class ClubService : INService, IClubService
var member = uow.Set<ClubInfo>().GetByMember(user.Id);
return member;
}
public async Task<SetClubIconResult> SetClubIconAsync(ulong ownerUserId, string url)
{
if (url is not null)
{
using var http = _httpFactory.CreateClient();
using var temp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (!temp.IsImage())
if (!temp.IsImage())
return SetClubIconResult.InvalidFileType;
if (temp.GetContentLength() > 5.Megabytes())
return SetClubIconResult.TooLarge;
}
@ -134,6 +134,18 @@ public class ClubService : INService, IClubService
return club is not null;
}
public async Task<int> GetClubRankAsync(int clubId)
{
await using var uow = _db.GetDbContext();
var rank = await uow.Clubs
.ToLinqToDBTable()
.Where(x => x.Xp > (uow.Clubs.First(c => c.Id == clubId).Xp))
.CountAsyncLinqToDB();
return rank + 1;
}
public ClubApplyResult ApplyToClub(IUser user, ClubInfo club)
{
using var uow = _db.GetDbContext();
@ -144,10 +156,10 @@ public class ClubService : INService, IClubService
// or doesn't min minumum level requirement, can't apply
if (du.ClubId is not null)
return ClubApplyResult.AlreadyInAClub;
if (club.Bans.Any(x => x.UserId == du.Id))
return ClubApplyResult.Banned;
if (club.Applicants.Any(x => x.UserId == du.Id))
return ClubApplyResult.AlreadyApplied;
@ -162,7 +174,7 @@ public class ClubService : INService, IClubService
return ClubApplyResult.Success;
}
public ClubAcceptResult AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser)
{
discordUser = null;
@ -188,7 +200,7 @@ public class ClubService : INService, IClubService
uow.SaveChanges();
return ClubAcceptResult.Accepted;
}
public ClubDenyResult RejectApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser)
{
discordUser = null;
@ -201,9 +213,9 @@ public class ClubService : INService, IClubService
club.Applicants.FirstOrDefault(x => x.User.ToString().ToUpperInvariant() == userName.ToUpperInvariant());
if (applicant is null)
return ClubDenyResult.NoSuchApplicant;
club.Applicants.Remove(applicant);
discordUser = applicant.User;
uow.SaveChanges();
return ClubDenyResult.Rejected;
@ -220,7 +232,7 @@ public class ClubService : INService, IClubService
using var uow = _db.GetDbContext();
var du = uow.GetOrCreateUser(user, x => x.Include(u => u.Club));
if (du.Club is null)
return ClubLeaveResult.NotInAClub;
return ClubLeaveResult.NotInAClub;
if (du.Club.OwnerId == du.Id)
return ClubLeaveResult.OwnerCantLeave;
@ -306,7 +318,7 @@ public class ClubService : INService, IClubService
return ClubUnbanResult.Success;
}
public ClubKickResult Kick(ulong kickerId, string userName, out ClubInfo club)
{
using var uow = _db.GetDbContext();
@ -342,14 +354,14 @@ public class ClubService : INService, IClubService
{
if (!CheckClubName(clubName))
return ClubRenameResult.NameTooLong;
await using var uow = _db.GetDbContext();
var club = uow.Set<ClubInfo>().GetByOwnerOrAdmin(userId);
if (club is null)
return ClubRenameResult.NotOwnerOrAdmin;
if (await uow.Set<ClubInfo>().AnyAsyncEF(x => x.Name == clubName))
return ClubRenameResult.NameTaken;

View file

@ -23,6 +23,7 @@ public interface IClubService
ClubKickResult Kick(ulong kickerId, string userName, out ClubInfo club);
List<ClubInfo> GetClubLeaderboardPage(int page);
Task<ClubRenameResult> RenameClubAsync(ulong userId, string clubName);
Task<int> GetClubRankAsync(int clubId);
}
public enum ClubApplyResult

View file

@ -4,7 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>true</ImplicitUsings>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<Version>5.1.1</Version>
<Version>5.1.3</Version>
<!-- Output/build -->
<RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory>

View file

@ -147,4 +147,5 @@ public static class StringExtensions
var newString = str.UnescapeUnicodeCodePoint();
return newString;
});
}

View file

@ -1,7 +1,30 @@
using System.Globalization;
namespace NadekoBot.Extensions;
public static class NumberExtensions
{
public static DateTimeOffset ToUnixTimestamp(this double number)
=> new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(number);
public static string ToShortString(this decimal value)
{
if (value <= 1_000)
return Math.Round(value, 2).ToString(CultureInfo.InvariantCulture);
if (value <= 1_000_000)
return Math.Round(value, 1).ToString(CultureInfo.InvariantCulture);
var tokens = " MBtq";
var i = 2;
while (true)
{
var num = (decimal)Math.Pow(1000, i);
if (num > value)
{
var num2 = (decimal)Math.Pow(1000, i - 1);
return $"{Math.Round((value / num2), 1)}{tokens[i - 1]}".Trim();
}
i++;
}
}
}

View file

@ -1404,4 +1404,8 @@ cleanupguilddata:
prompt:
- prompt
honeypot:
- honeypot
- honeypot
coins:
- coins
- crypto
- cryptos

View file

@ -835,7 +835,12 @@ setservericon:
- img:
desc: "The URL of the image file to be displayed as the bot's banner."
send:
desc: 'Sends a message to a channel or user. Channel or user can be '
desc: |-
Sends a message to a channel or user.
You can write "channel" (literally word 'channel') first followed by the channel id or channel mention, or
You can write "user" (literally word 'user') first followed by the user id or user mention.
After either one of those, specify the message to be sent.
This command can only be used by the Bot Owner.
ex:
- channel 123123123132312 Stop spamming commands plz
- user 1231231232132 I can see in the console what you're doing.
@ -3755,7 +3760,11 @@ expredit:
message:
desc: "The text that will replace the original response in the expression's output."
say:
desc: Bot will send the message you typed in the specified channel. If you omit the channel name, it will send the message in the current channel. Supports embeds.
desc: |-
Make the bot say something, or in other words, make the bot send the message.
You can optionally specify the channel where the bot will send the message.
If you omit the channel name, it will send the message in the current channel.
Supports embeds.
ex:
- hi
- '#chat hi'
@ -4256,8 +4265,10 @@ bankbalance:
Shows how much currency is in your bank account.
This differs from your cash amount, as the cash amount is publicly available, but only you have access to your bank balance.
However, you have to withdraw it first in order to use it.
Bot Owner can also check another user's bank balance.
ex:
- ''
- '@User'
params:
- {}
banktake:
@ -4545,4 +4556,15 @@ honeypot:
ex:
- ''
params:
- {}
- {}
coins:
desc: |-
Shows a list of 10 crypto currencies ordered by market cap.
Shows their price, change in the last24h, market cap and circulating and total supply.
Paginated with 10 per page.
ex:
- ''
- '2'
params:
- page:
desc: "Page number to show. Starts at 1."

View file

@ -889,6 +889,7 @@
"club_kick_hierarchy": "Only club owner can kick club admins. Owner can't be kicked.",
"club_renamed": "Club has been renamed to {0}",
"club_name_taken": "A club with that name already exists.",
"rank": "Rank",
"template_reloaded": "Xp template has been reloaded.",
"expr_edited": "Expression Edited",
"self_assign_are_exclusive": "You can only choose 1 role from each group.",
@ -1039,6 +1040,7 @@
"medusa_already_loaded": "Medusa {0} is already loaded",
"medusa_invalid_not_found": "Medusa with that name wasn't found or the file was invalid",
"bank_balance": "You have {0} in your bank account.",
"bank_balance_other": "User {0} has {1} in the bank.",
"bank_deposited": "You deposited {0} to your bank account.",
"bank_withdrew": "You withdrew {0} from your bank account.",
"bank_withdraw_insuff": "You don't have sufficient {0} in your bank account.",