1 // ----------------------------------------------------------------------------------------------
3 // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___
4 // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \
5 // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | |
6 // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_|
7 // ----------------------------------------------------------------------------------------------
9 // Copyright 2015-2024 Ćukasz "JustArchi" Domeradzki
10 // Contact: JustArchi@JustArchi.net
12 // Licensed under the Apache License, Version 2.0 (the "License");
13 // you may not use this file except in compliance with the License.
14 // You may obtain a copy of the License at
16 // http://www.apache.org/licenses/LICENSE-2.0
18 // Unless required by applicable law or agreed to in writing, software
19 // distributed under the License is distributed on an "AS IS" BASIS,
20 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21 // See the License for the specific language governing permissions and
22 // limitations under the License.
25 using System
.Collections
.Generic
;
26 using System
.ComponentModel
.DataAnnotations
;
27 using System
.Composition
;
28 using System
.Diagnostics
.CodeAnalysis
;
30 using System
.Text
.Json
;
31 using System
.Text
.Json
.Serialization
;
32 using System
.Threading
.Tasks
;
33 using ArchiSteamFarm
.Core
;
34 using ArchiSteamFarm
.Plugins
.Interfaces
;
35 using ArchiSteamFarm
.Steam
;
36 using ArchiSteamFarm
.Steam
.Data
;
37 using ArchiSteamFarm
.Steam
.Exchange
;
40 namespace ArchiSteamFarm
.CustomPlugins
.ExamplePlugin
;
42 // In order for your plugin to work, it must export generic ASF's IPlugin interface
43 [Export(typeof(IPlugin
))]
45 // Your plugin class should inherit the plugin interfaces it wants to handle
46 // If you do not want to handle a particular action (e.g. OnBotMessage that is offered in IBotMessage), it's the best idea to not inherit it at all
47 // This will keep your code compact, efficient and less dependent. You can always add additional interfaces when you'll need them, this example project will inherit quite a bit of them to show you potential usage
48 [SuppressMessage("ReSharper", "MemberCanBeFileLocal")]
49 internal sealed class ExamplePlugin
: IASF
, IBot
, IBotCommand2
, IBotConnection
, IBotFriendRequest
, IBotMessage
, IBotModules
, IBotTradeOffer2
{
50 // This is used for identification purposes, typically you want to use a friendly name of your plugin here, such as the name of your main class
51 // Please note that this property can have direct dependencies only on structures that were initialized by the constructor, as it's possible to be called before OnLoaded() takes place
54 public string Name
=> nameof(ExamplePlugin
);
56 // This will be displayed to the user and written in the log file, typically you should point it to the version of your library, but alternatively you can do some more advanced logic if you'd like to
57 // Please note that this property can have direct dependencies only on structures that were initialized by the constructor, as it's possible to be called before OnLoaded() takes place
60 public Version Version
=> typeof(ExamplePlugin
).Assembly
.GetName().Version
?? throw new InvalidOperationException(nameof(Version
));
62 // Plugins can expose custom properties for our GET /Api/Plugins API call, simply annotate them with [JsonProperty] (or keep public)
65 public bool CustomIsEnabledField { get; private init; }
= true;
67 // This method, apart from being called before any bot initialization takes place, allows you to read custom global config properties that are not recognized by ASF
68 // Thanks to that, you can extend default ASF config with your own stuff, then parse it here in order to customize your plugin during runtime
69 // Keep in mind that, as noted in the interface, additionalConfigProperties can be null if no custom, unrecognized properties are found by ASF, you should handle that case appropriately
70 // In addition to that, this method also guarantees that all plugins were already OnLoaded(), which allows cross-plugins-communication to be possible
71 public Task
OnASFInit(IReadOnlyDictionary
<string, JsonElement
>? additionalConfigProperties
= null) {
72 if (additionalConfigProperties
== null) {
73 return Task
.CompletedTask
;
76 foreach ((string configProperty
, JsonElement configValue
) in additionalConfigProperties
) {
77 // It's a good idea to prefix your custom properties with the name of your plugin, so there will be no possible conflict of ASF or other plugins using the same name, neither now or in the future
78 switch (configProperty
) {
79 case $"{nameof(ExamplePlugin)}TestProperty" when configValue
.ValueKind
== JsonValueKind
.True
:
80 ASF
.ArchiLogger
.LogGenericInfo($"{nameof(ExamplePlugin)}TestProperty boolean property has been found with a value of true");
86 // ASF interface methods usually expect a Task as a return value, this allows you to optionally implement async operations in your functions (with async Task function signature)
87 // If your method does not implement any async operations (is fully synchronous), you could in theory still mark it as async, but a better idea is to just return Task.CompletedTask from it, like here
88 return Task
.CompletedTask
;
91 // This method is called when unknown command is received (starting with CommandPrefix)
92 // This allows you to recognize the command yourself and implement custom commands
93 // Keep in mind that there is no guarantee what is the actual access of steamID, so you should do the appropriate access checking yourself
94 // You can use either ASF's default functions for that, or implement your own logic as you please
95 // Since ASF already had to do initial parsing in order to determine that the command is unknown, args[] are splitted using standard ASF delimiters
96 // If by any chance you want to handle message in its raw format, you also have it available, although for usual ASF pattern you can most likely stick with args[] exclusively. The message has CommandPrefix already stripped for your convenience
97 // If you do not recognize the command, just return null/empty and allow ASF to gracefully return "unknown command" to user on usual basis
98 public async Task
<string?> OnBotCommand(Bot bot
, EAccess access
, string message
, string[] args
, ulong steamID
= 0) {
99 // In comparison with OnBotMessage(), we're using asynchronous CatAPI call here, so we declare our method as async and return the message as usual
100 // Notice how we handle access here as well, it'll work only for FamilySharing+
101 switch (args
[0].ToUpperInvariant()) {
102 case "CAT" when access
>= EAccess
.FamilySharing
:
103 // Notice how we can decide whether to use bot's AWH WebBrowser or ASF's one. For Steam-related requests, AWH's one should always be used, for third-party requests like those it doesn't really matter
104 // Still, it makes sense to pass AWH's one, so in case you get some errors or alike, you know from which bot instance they come from. It's similar to using Bot's ArchiLogger compared to ASF's one
105 Uri
? randomCatURL
= await CatAPI
.GetRandomCatURL(bot
.ArchiWebHandler
.WebBrowser
).ConfigureAwait(false);
107 return randomCatURL
!= null ? randomCatURL
.ToString() : "God damn it, we're out of cats, care to notify my master? Thanks!";
113 // This method is called when bot is destroyed, e.g. on config removal
114 // You should ensure that all of your references to this bot instance are cleared - most of the time this is anything you created in OnBotInit(), including deep roots in your custom modules
115 // This doesn't have to be done immediately (e.g. no need to cancel existing work), but it should be done in timely manner when everything is finished
116 // Doing so will allow the garbage collector to dispose the bot afterwards, refraining from doing so will create a "memory leak" by keeping the reference alive
117 public Task
OnBotDestroy(Bot bot
) => Task
.CompletedTask
;
119 // This method is called when bot is disconnected from Steam network, you may want to use this info in some kind of way, or not
120 // ASF tries its best to provide logical reason why the disconnection has happened, and will use EResult.OK if the disconnection was initiated by us (e.g. as part of a command)
121 // Still, you should take anything other than EResult.OK with a grain of salt, unless you want to assume that Steam knows why it disconnected us (hehe, you bet)
122 public Task
OnBotDisconnected(Bot bot
, EResult reason
) => Task
.CompletedTask
;
124 // This method is called when bot receives a friend request or group invite that ASF isn't willing to accept
125 // It allows you to generate a response whether ASF should accept it (true) or proceed like usual (false)
126 // If you wanted to do extra filtering (e.g. friend requests only), you can interpret the steamID as SteamID (SteamKit2 type) and then operate on AccountType
127 // As an example, we'll run a trade bot that is open to all friend/group invites, therefore we'll accept all of them here
128 public Task
<bool> OnBotFriendRequest(Bot bot
, ulong steamID
) => Task
.FromResult(true);
130 // This method is called at the end of Bot's constructor
131 // You can initialize all your per-bot structures here
132 // In general you should do that only when you have a particular need of custom modules or alike, since ASF's plugin system will always provide bot to you as a function argument
133 public Task
OnBotInit(Bot bot
) {
134 // Apart of those two that are already provided by ASF, you can also initialize your own logger with your plugin's name, if needed
135 bot
.ArchiLogger
.LogGenericInfo($"Our bot named {bot.BotName} has been initialized, and we're letting you know about it from our {nameof(ExamplePlugin)}!");
136 ASF
.ArchiLogger
.LogGenericWarning("In case we won't have a bot reference or have something process-wide to log, we can also use ASF's logger!");
138 return Task
.CompletedTask
;
141 // This method, apart from being called during bot modules initialization, allows you to read custom bot config properties that are not recognized by ASF
142 // Thanks to that, you can extend default bot config with your own stuff, then parse it here in order to customize your plugin during runtime
143 // Keep in mind that, as noted in the interface, additionalConfigProperties can be null if no custom, unrecognized properties are found by ASF, you should handle that case appropriately
144 // Also keep in mind that this function can be called multiple times, e.g. when user edits their bot configs during runtime
145 // Take a look at OnASFInit() for example parsing code
146 public async Task
OnBotInitModules(Bot bot
, IReadOnlyDictionary
<string, JsonElement
>? additionalConfigProperties
= null) {
147 // For example, we'll ensure that every bot starts paused regardless of Paused property, in order to do this, we'll just call Pause here in InitModules()
148 // Thanks to the fact that this method is called with each bot config reload, we'll ensure that our bot stays paused even if it'd get unpaused otherwise
149 bot
.ArchiLogger
.LogGenericInfo("Pausing this bot as asked from the plugin");
150 await bot
.Actions
.Pause(true).ConfigureAwait(false);
153 // This method is called when the bot is successfully connected to Steam network and it's a good place to schedule any on-connected tasks, as AWH is also expected to be available shortly
154 public Task
OnBotLoggedOn(Bot bot
) => Task
.CompletedTask
;
156 // This method is called when bot receives a message that is NOT a command (in other words, a message that doesn't start with CommandPrefix)
157 // Normally ASF entirely ignores such messages as the program should not respond to something that isn't recognized
158 // Therefore this function allows you to catch all such messages and handle them yourself
159 // Keep in mind that there is no guarantee what is the actual access of steamID, so you should do the appropriate access checking yourself
160 // You can use either ASF's default functions for that, or implement your own logic as you please
161 // If you do not intend to return any response to user, just return null/empty and ASF will proceed with the silence as usual
162 public Task
<string?> OnBotMessage(Bot bot
, ulong steamID
, string message
) {
163 // Normally ASF will expect from you async-capable responses, such as Task<string>. This allows you to make your code fully asynchronous which is a core foundation on which ASF is built upon
164 // Since in this method we're not doing any async stuff, instead of defining this method as async (pointless), we just need to wrap our responses in Task.FromResult<>()
165 if (Bot
.BotsReadOnly
== null) {
166 throw new InvalidOperationException(nameof(Bot
.BotsReadOnly
));
169 // As a starter, we can for example ignore messages sent from our own bots, since otherwise they can run into a possible infinite loop of answering themselves
170 if (Bot
.BotsReadOnly
.Values
.Any(existingBot
=> existingBot
.SteamID
== steamID
)) {
171 return Task
.FromResult
<string?>(null);
174 // If this message doesn't come from one of our bots, we can reply to the user in some pre-defined way
175 bot
.ArchiLogger
.LogGenericTrace("Hey boss, we got some unknown message here!");
177 return Task
.FromResult
<string?>("I didn't get that, did you mean to use a command?");
180 // This method is called when bot receives a trade offer that ASF isn't willing to accept (ignored and rejected trades)
181 // It allows you not only to analyze such trades, but generate a response whether ASF should accept it (true), or proceed like usual (false)
182 // Thanks to that, you can implement custom rules for all trades that aren't handled by ASF, for example cross-set trading on your own custom rules
183 // You'd implement your own logic here, as an example we'll allow all trades to be accepted if the bot's name starts from "TrashBot"
184 public Task
<bool> OnBotTradeOffer(Bot bot
, TradeOffer tradeOffer
, ParseTradeResult
.EResult asfResult
) => Task
.FromResult(bot
.BotName
.StartsWith("TrashBot", StringComparison
.OrdinalIgnoreCase
));
186 // This is the earliest method that will be called, right after loading the plugin, long before any bot initialization takes place
187 // It's a good place to initialize all potential (non-bot-specific) structures that you will need across lifetime of your plugin, such as global timers, concurrent dictionaries and alike
188 // If you do not have any global structures to initialize, you can leave this function empty
189 // At this point you can access core ASF's functionality, such as logging, but more advanced structures (like ASF's WebBrowser) will be available in OnASFInit(), which itself takes place after every plugin gets OnLoaded()
190 // Typically you should use this function only for preparing core structures of your plugin, and optionally also sending a message to the user (e.g. support link, welcome message or similar), ASF-specific things should usually happen in OnASFInit()
191 public Task
OnLoaded() {
192 ASF
.ArchiLogger
.LogGenericInfo($"Hey! Thanks for checking if our example plugin works fine, this is a confirmation that indeed {nameof(OnLoaded)}() method was called!");
193 ASF
.ArchiLogger
.LogGenericInfo("Good luck in whatever you're doing!");
195 return Task
.CompletedTask
;