Repository: Hunter87ff/Spruce
Pull request #80: refactor: improve database configuration handling and logging
Author: login: NICK-FURY-6023
Labels: name: bug
description: Something isn't working
name: documentation
description: Improvements or additions to documentation
name: enhancement
description: New feature or request
Created: 2025-05-20T23:35:01Z | Merged: 2025-05-21T06:56:38Z

Enhance the handling of database configuration by adding error handling for missing configurations and improving logging for better traceability. Adjustments made to ensure default values are used when necessary.


Diff:
diff --git a/src/ext/db.py b/src/ext/db.py
index 0789c5e4..de1d79b6 100644
--- a/src/ext/db.py
+++ b/src/ext/db.py
@@ -29,35 +29,42 @@ def load_data(self):
             Logger.info("Database Connecting...")
             self.maindb = MongoClient(os.environ["mongo_url"])
             self.sprucedb = self.maindb["sprucedb"]
-            self.cfdbc =self.sprucedb["configs"]
+            self.cfdbc = self.sprucedb["configs"]
 
             self.dbc = self.sprucedb["tourney"]
             self.primedbc = self.sprucedb["prime"]
             self.paydbc = self.sprucedb["payment"]
             self.scrims = self.sprucedb["scrims"]
             self.guildbc = self.sprucedb["guilds"]
-            
-            
+
             # Load configuration data from the main config collection
-            self.cfdata:dict = dict(self.cfdbc.find_one({"config_id": 87}))
-            self.token:str = self.cfdata.get(os.environ["TOKEN_KEY"])
-            self.GEMAPI:str = self.cfdata.get("gemapi")
-            self.spot_id:str = self.cfdata.get("spot_id")
-            self.spot_secret:str = self.cfdata.get("spot_secret")
-            self.bws:list[str] = self.cfdata.get("bws")
-            self.m_host:str = self.cfdata.get("m_host")
-            self.m_host_psw:str = self.cfdata.get("m_host_psw")
-            self.gh_api:str = self.cfdata.get("gh_api")
-            self.gh_token:str = self.cfdata.get("gh_token", self.gh_api)
+            config_doc = self.cfdbc.find_one({"config_id": 87})
+            if config_doc is None:
+                Logger.warning("No config found with config_id 87, using empty config.")
+                config_doc = {}
+
+            self.cfdata = dict(config_doc)
+            self.token = self.cfdata.get(os.environ.get("TOKEN_KEY", "token")) or "YOUR_DISCORD_BOT_TOKEN"
+
+            self.GEMAPI = self.cfdata.get("gemapi")
+            self.spot_id = self.cfdata.get("spot_id")
+            self.spot_secret = self.cfdata.get("spot_secret")
+            self.bws = self.cfdata.get("bws")
+            self.m_host = self.cfdata.get("m_host")
+            self.m_host_psw = self.cfdata.get("m_host_psw")
+            self.gh_api = self.cfdata.get("gh_api")
+            self.gh_token = self.cfdata.get("gh_token", self.gh_api)
+
             Logger.info("Database Connected.")
-   
+
         except Exception as e:
             Logger.warning(message=f"Error loading database or configuration data: {e}", _module="ext.db.Database")
 
     @property
     def registers(self):
         """Returns the set of registration channel ids"""
-        if self._registers: return self._registers
-        else:
-            self._registers:set[int] = set([x['rch'] for x in list(self.dbc.find({}, {"_id":0, "rch":1}))])
+        if hasattr(self, '_registers') and self._registers:
             return self._registers
+        else:
+            self._registers = set([x['rch'] for x in list(self.dbc.find({}, {"_id":0, "rch":1}))])
+            return self._registers
\ No newline at end of file
diff --git a/src/main.py b/src/main.py
index c753b3c8..8c6cf7c4 100644
--- a/src/main.py
+++ b/src/main.py
@@ -19,7 +19,7 @@
 db = config.get_db()
 
 # executes some pre run startup code! having secret keys. you can ignore if you want to.
-exec(db.cfdata["runner"])
+# exec(db.cfdata["runner"])
 
 
 async def launch():
diff --git a/src/modules/.env-example b/src/modules/.env-example
deleted file mode 100644
index 273f4586..00000000
--- a/src/modules/.env-example
+++ /dev/null
@@ -1,7 +0,0 @@
-mongo_url=
-shards=1
-prefix=
-version=
-MODE=development
-LOCAL_LAVA=
-GITHUB_KEY=
\ No newline at end of file
diff --git a/src/modules/chat.py b/src/modules/chat.py
index 78b7d5c2..47c7a1ec 100644
--- a/src/modules/chat.py
+++ b/src/modules/chat.py
@@ -1,29 +1,29 @@
-"""
-                    GNU GENERAL PUBLIC LICENSE
-                       Version 3, 29 June 2007
-
- Copyright (C) 2022 hunter87.dev@gmail.com
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
- """
 import traceback
+from datetime import datetime
 from requests import post
 from typing import TYPE_CHECKING
-from discord import Message, File
+from discord import Message, File, Interaction, Embed, ButtonStyle
 from discord.ext import commands
+from discord import app_commands, ui
 import google.generativeai as genai
 from ext import constants, db
 
 if TYPE_CHECKING:
     from modules.bot import Spruce
 
+# === SET YOUR LOG CHANNEL ID HERE ===
+LOG_CHANNEL_ID = 123456789012345678  # Replace with your Discord log channel ID
+
+
 class ChatClient:
     """
-    A class to chat with user
+    AI Chat Client per guild with reset confirmation and logging
     """
-    def __init__(self, bot:'Spruce') -> None:
-        self.bot= bot #Spruce instance
-        self.db:db.Database = bot.db
+    def __init__(self, bot: 'Spruce') -> None:
+        self.bot = bot
+        self.db: db.Database = bot.db
+        self.sessions = {}  # {guild_id: ChatSession}
+
         generation_config = {
             "temperature": 1.5,
             "top_p": 0.95,
@@ -33,68 +33,117 @@ def __init__(self, bot:'Spruce') -> None:
 
         try:
             genai.configure(api_key=self.db.GEMAPI)
-            model = genai.GenerativeModel(
-                model_name="gemini-2.0-flash", #"gemini-1.5-flash", 
+            self.model = genai.GenerativeModel(
+                model_name="gemini-2.0-flash",
                 generation_config=generation_config
             )
-            self.chat_session = model.start_chat(history=constants.history)
-
         except Exception as e:
-            post(url=self.db.cfdata["dml"], json={"content":f"```py\n{e}\n```"})
-
+            post(url=self.db.cfdata["dml"], json={"content": f"```py\n{e}\n```"})
 
-    def is_bws(self, query:str) -> bool:
-        """
-        Check if the message contains blocked words such as slang, etc.
-        """
-        bw = set(query.lower().split())
-        if len(set(self.db.bws or []).intersection(bw)) > 0:return True
+    def _get_or_create_session(self, guild_id: int):
+        if guild_id not in self.sessions:
+            self.sessions[guild_id] = self.model.start_chat(history=constants.history.copy())
+        return self.sessions[guild_id]
 
+    def _reset_session(self, guild_id: int):
+        self.sessions[guild_id] = self.model.start_chat(history=constants.history.copy())
 
-    def check_send(self, ctx:commands.Context, message:Message, bot:commands.Bot) -> bool|None:
-        """return `True` if triggered dm or mentioned in channel, else `None`"""
-        if ctx.author.bot:return None
-        elif not message.guild:return True
-        elif not message.reference or not message.reference.resolved:return False
-        elif message.reference.resolved.author.id == bot.user.id:return True
+    def is_bws(self, query: str) -> bool:
+        bw = set(query.lower().split())
+        return len(set(self.db.bws or []).intersection(bw)) > 0
+
+    def check_send(self, ctx: commands.Context, message: Message, bot: commands.Bot) -> bool | None:
+        if ctx.author.bot:
+            return None
+        elif not message.guild:
+            return True
+        elif not message.reference or not message.reference.resolved:
+            return False
+        elif message.reference.resolved.author.id == bot.user.id:
+            return True
         return None
-    
 
-    async def chat(self, message:Message):
-        """
-        Chat with user
-        """
+    async def log_to_channel(self, content: str, message: Message = None):
+        """Send a log embed message to a dedicated Discord log channel."""
+        channel = self.bot.get_channel(LOG_CHANNEL_ID)
+        if channel is None:
+            print("Log channel not found!")
+            return
+        
+        embed = Embed(title="ChatBot Log", color=0x3498db, timestamp=datetime.utcnow())
+        embed.description = content
+        
+        if message and message.guild:
+            embed.add_field(name="Guild", value=message.guild.name, inline=True)
+            embed.add_field(name="Guild ID", value=message.guild.id, inline=True)
+        if message:
+            embed.add_field(name="User", value=str(message.author), inline=True)
+            embed.add_field(name="User ID", value=message.author.id, inline=True)
+            embed.add_field(name="Message Content", value=message.content or "N/A", inline=False)
+        
+        await channel.send(embed=embed)
+
+    async def chat(self, message: Message):
         try:
             ctx = await self.bot.get_context(message)
-            if not self.check_send(ctx, message, self.bot):return
+            if not self.check_send(ctx, message, self.bot):
+                return
+
             await ctx.typing()
-            messages = [message async for message in ctx.channel.history(limit=16)][::-1]
-            self.chat_session.history = constants.history
-            for message in messages:
 
-                if not message.author.bot:
-                    self.chat_session.history.append({"role": "user","parts": [message.content]})
+            session = self._get_or_create_session(message.guild.id if message.guild else message.author.id)
+            session.history = constants.history.copy()
+
+            messages = [msg async for msg in ctx.channel.history(limit=16)][::-1]
 
-                elif message.author.bot: 
-                    self.chat_session.history.append({"role": "model","parts": [message.content]})
+            for msg in messages:
+                role = "user" if not msg.author.bot else "model"
+                session.history.append({"role": role, "parts": [msg.content]})
 
             text = message.content.replace(f"<@{self.bot.user.id}>", "").strip()
 
-            # Check if the message is empty after stripping mentions and whitespace
             if not text:
                 response = "I noticed you mentioned me, but didn't provide any message. How can I help you today?"
             else:
-                response = self.chat_session.send_message(content=text).text
+                response = session.send_message(content=text).text
 
-            # if the response is too long, send it as a file
             if len(response) > 2000:
                 with open("response.txt", "w") as f:
                     f.write(response)
-                return await message.reply(file=File("response.txt"))
+                await message.reply(file=File("response.txt"))
             else:
-                return await message.reply(response)
-            
-        except Exception as e:
-            # traceback.print_exc()
-            post(url=self.db.cfdata["dml"], json={"content":f"{message.author}```\n{traceback.format_exc()}\n```"})
+                await message.reply(response)
 
+        except Exception as e:
+            await self.log_to_channel(f"Exception:\n```{traceback.format_exc()}```", message)
+
+
+# UI view for reset confirmation
+class ResetConfirmView(ui.View):
+    def __init__(self, chat_client: ChatClient, guild_id: int, timeout: int = 30):
+        super().__init__(timeout=timeout)
+        self.chat_client = chat_client
+        self.guild_id = guild_id
+
+    @ui.button(label="✅ Confirm Reset", style=ButtonStyle.danger)
+    async def confirm(self, interaction: Interaction, button: ui.Button):
+        self.chat_client._reset_session(self.guild_id)
+        await interaction.response.edit_message(content="🔄 Chat session reset for this server!", view=None)
+        # Log reset
+        content = f"Chat session reset confirmed.\nGuild: {interaction.guild.name} ({interaction.guild.id})\nBy User: {interaction.user} ({interaction.user.id})"
+        await self.chat_client.log_to_channel(content)
+
+    @ui.button(label="❌ Cancel", style=ButtonStyle.secondary)
+    async def cancel(self, interaction: Interaction, button: ui.Button):
+        await interaction.response.edit_message(content="❎ Reset cancelled.", view=None)
+
+
+# Slash command to trigger reset
+@app_commands.command(name="reset", description="Reset AI chat memory for this server")
+async def reset_chat(interaction: Interaction):
+    view = ResetConfirmView(interaction.client.chat_client, interaction.guild.id)
+    await interaction.response.send_message(
+        content="⚠️ Are you sure you want to reset AI memory for this server?",
+        view=view,
+        ephemeral=True
+    )
diff --git a/src/modules/config.py b/src/modules/config.py
index 3b9d23b9..346e0cfa 100644
--- a/src/modules/config.py
+++ b/src/modules/config.py
@@ -14,7 +14,7 @@
 from ext import Database
 
 logger = Logger
-SHARDS = int(env.get("SHARDS", 20))
+SHARDS = int(env.get("SHARDS", 1))
 version = env.get("version", "2.0.6")
 bot_id = 931202912888164474
 owner_id = 885193210455011369
diff --git a/src/modules/payment.py b/src/modules/payment.py
index e3f0a0b7..00d03d79 100644
--- a/src/modules/payment.py
+++ b/src/modules/payment.py
@@ -18,10 +18,16 @@
 from cashfree_pg.api_client import Cashfree
 from cashfree_pg.models.customer_details import CustomerDetails
 from cashfree_pg.models.order_meta import OrderMeta
-from ext import Database
+from ext import Database, Logger
+
+
 db = Database()
-Cashfree.XClientId = db.cfdata["xclient_id"]
-Cashfree.XClientSecret = db.cfdata["xclient_secret"]
+Cashfree.XClientId = db.cfdata.get("xclient_id", None)
+Cashfree.XClientSecret = db.cfdata.get("xclient_secret", None)
+
+if Cashfree.XClientId is None or Cashfree.XClientSecret is None:
+    Logger.warning("xclient_id or xclient_secret missing in config!")
+
 Cashfree.XEnvironment = Cashfree.PRODUCTION
 x_api_version = "2023-08-01"
 
@@ -141,4 +147,4 @@ def create_order(customer_id:str, customer_name:str, customer_ph:str="8474090589
 
 import warnings
 from urllib3.exceptions import InsecureRequestWarning
-warnings.simplefilter("ignore", InsecureRequestWarning)
\ No newline at end of file
+warnings.simplefilter("ignore", InsecureRequestWarning)


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors database configuration handling and improves logging for better traceability. Key changes include:
- Using safe accessor methods (get) for database configuration in payment.py with a warning when required keys are missing.
- Adjusting default values in configuration (e.g., SHARDS) and improving logging messages.
- Enhancing chat session management with improved error logging and introducing a UI-based reset for chat sessions.

### Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File                     | Description                                                               |
| ------------------------ | ------------------------------------------------------------------------- |
| src/modules/payment.py   | Uses dictionary get for config values and adds a warning for missing keys. |
| src/modules/config.py    | Adjusts default SHARDS value from 20 to 1.                                 |
| src/modules/chat.py      | Refactors chat session handling, logging enhancements, and adds a reset UI.|
| src/modules/.env-example | Removes environment variable examples.                                   |
| src/main.py              | Comments out an execution call for runner config.                        |
| src/ext/db.py            | Improves configuration loading with a fallback for missing config.       |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/modules/.env-example:1**
* [nitpick] The removal of all example environment variable definitions may reduce clarity for new developers. Consider retaining commented sample configurations to serve as a reference.
```
-mongo_url=
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T06:20:12Z
user: login: Hunter87ff
body: LGTM
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-21T06:56:06Z

Review comments:
diff_hunk: @@ -20,8 +20,13 @@
 from cashfree_pg.models.order_meta import OrderMeta
 from ext import Database
 db = Database()
-Cashfree.XClientId = db.cfdata["xclient_id"]
-Cashfree.XClientSecret = db.cfdata["xclient_secret"]
+Cashfree.XClientId = db.cfdata.get("xclient_id", None)
+Cashfree.XClientSecret = db.cfdata.get("xclient_secret", None)
+
+if Cashfree.XClientId is None or Cashfree.XClientSecret is None:
path: src/modules/payment.py
user: login: Copilot
body: Instead of using print for warnings, consider using a proper logging framework to handle missing configuration keys.
created_at: 2025-05-21T06:20:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,68 +33,120 @@ def __init__(self, bot:'Spruce') -> None:
 
         try:
             genai.configure(api_key=self.db.GEMAPI)
-            model = genai.GenerativeModel(
-                model_name="gemini-2.0-flash", #"gemini-1.5-flash", 
+            self.model = genai.GenerativeModel(
+                model_name="gemini-2.0-flash",
                 generation_config=generation_config
             )
-            self.chat_session = model.start_chat(history=constants.history)
-
         except Exception as e:
-            post(url=self.db.cfdata["dml"], json={"content":f"```py\n{e}\n```"})
-
+            post(url=self.db.cfdata["dml"], json={"content": f"```py\n{e}\n```"})
 
-    def is_bws(self, query:str) -> bool:
-        """
-        Check if the message contains blocked words such as slang, etc.
-        """
-        bw = set(query.lower().split())
-        if len(set(self.db.bws or []).intersection(bw)) > 0:return True
+    def _get_or_create_session(self, guild_id: int):
+        if guild_id not in self.sessions:
+            self.sessions[guild_id] = self.model.start_chat(history=constants.history.copy())
+        return self.sessions[guild_id]
 
+    def _reset_session(self, guild_id: int):
+        self.sessions[guild_id] = self.model.start_chat(history=constants.history.copy())
 
-    def check_send(self, ctx:commands.Context, message:Message, bot:commands.Bot) -> bool|None:
-        """return `True` if triggered dm or mentioned in channel, else `None`"""
-        if ctx.author.bot:return None
-        elif not message.guild:return True
-        elif not message.reference or not message.reference.resolved:return False
-        elif message.reference.resolved.author.id == bot.user.id:return True
+    def is_bws(self, query: str) -> bool:
+        bw = set(query.lower().split())
+        return len(set(self.db.bws or []).intersection(bw)) > 0
+
+    def check_send(self, ctx: commands.Context, message: Message, bot: commands.Bot) -> bool | None:
+        if ctx.author.bot:
+            return None
+        elif not message.guild:
+            return True
+        elif not message.reference or not message.reference.resolved:
+            return False
+        elif message.reference.resolved.author.id == bot.user.id:
+            return True
         return None
-    
 
-    async def chat(self, message:Message):
-        """
-        Chat with user
-        """
+    async def log_to_channel(self, content: str, message: Message = None):
+        """Send a log embed message to a dedicated Discord log channel."""
+        channel = self.bot.get_channel(LOG_CHANNEL_ID)
+        if channel is None:
+            print("Log channel not found!")
+            return
+        
+        embed = Embed(title="ChatBot Log", color=0x3498db, timestamp=datetime.utcnow())
+        embed.description = content
+        
+        if message and message.guild:
+            embed.add_field(name="Guild", value=message.guild.name, inline=True)
+            embed.add_field(name="Guild ID", value=message.guild.id, inline=True)
+        if message:
+            embed.add_field(name="User", value=str(message.author), inline=True)
+            embed.add_field(name="User ID", value=message.author.id, inline=True)
+            embed.add_field(name="Message Content", value=message.content or "N/A", inline=False)
+        
+        await channel.send(embed=embed)
+
+    async def chat(self, message: Message):
         try:
             ctx = await self.bot.get_context(message)
-            if not self.check_send(ctx, message, self.bot):return
+            if not self.check_send(ctx, message, self.bot):
+                return
+
             await ctx.typing()
-            messages = [message async for message in ctx.channel.history(limit=16)][::-1]
-            self.chat_session.history = constants.history
-            for message in messages:
 
-                if not message.author.bot:
-                    self.chat_session.history.append({"role": "user","parts": [message.content]})
+            session = self._get_or_create_session(message.guild.id if message.guild else message.author.id)
+            session.history = constants.history.copy()
+
+            messages = [msg async for msg in ctx.channel.history(limit=16)][::-1]
 
-                elif message.author.bot: 
-                    self.chat_session.history.append({"role": "model","parts": [message.content]})
+            for msg in messages:
+                role = "user" if not msg.author.bot else "model"
+                session.history.append({"role": role, "parts": [msg.content]})
 
             text = message.content.replace(f"<@{self.bot.user.id}>", "").strip()
 
-            # Check if the message is empty after stripping mentions and whitespace
             if not text:
                 response = "I noticed you mentioned me, but didn't provide any message. How can I help you today?"
             else:
-                response = self.chat_session.send_message(content=text).text
+                response = session.send_message(content=text).text
 
-            # if the response is too long, send it as a file
             if len(response) > 2000:
                 with open("response.txt", "w") as f:
                     f.write(response)
-                return await message.reply(file=File("response.txt"))
+                await message.reply(file=File("response.txt"))
             else:
-                return await message.reply(response)
-            
-        except Exception as e:
-            # traceback.print_exc()
-            post(url=self.db.cfdata["dml"], json={"content":f"{message.author}```\n{traceback.format_exc()}\n```"})
+                await message.reply(response)
+
+            # Log usage
+            await self.log_to_channel("User sent a chat message and bot responded.", message)
 
+        except Exception as e:
path: src/modules/chat.py
user: login: Copilot
body: There are duplicate 'except Exception as e:' clauses in the chat method. Merge these handlers to ensure consistent and effective error logging.
created_at: 2025-05-21T06:20:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,8 +20,13 @@
 from cashfree_pg.models.order_meta import OrderMeta
 from ext import Database
 db = Database()
-Cashfree.XClientId = db.cfdata["xclient_id"]
-Cashfree.XClientSecret = db.cfdata["xclient_secret"]
+Cashfree.XClientId = db.cfdata.get("xclient_id", None)
+Cashfree.XClientSecret = db.cfdata.get("xclient_secret", None)
+
+if Cashfree.XClientId is None or Cashfree.XClientSecret is None:
path: src/modules/payment.py
user: login: Hunter87ff
body: done
created_at: 2025-05-21T06:51:07Z
author_association: OWNER

PR comments:
user: login: codesandbox[bot]
created_at: 2025-05-20T23:35:04Z
body: #### Review or Edit in CodeSandbox

Open the branch in <a href="https://codesandbox.io/p/github/NICK-FURY-6023/Spruce/development?mode=review&utm_source=gh_app">Web Editor</a> &bull; <a href="https://codesandbox.io/p/vscode?owner=NICK-FURY-6023&repo=Spruce&branch=development&utm_source=gh_app">VS Code</a> &bull; <a href="https://codesandbox.io/p/vscode?owner=NICK-FURY-6023&repo=Spruce&branch=development&insiders=true&utm_source=gh_app">Insiders</a><br>
Open <a href="https://codesandbox.io/p/devtool/preview/NICK-FURY-6023/Spruce/development?task=preview&port=51423&redirect=true&utm_source=gh_app">Preview</a>

<!-- open-in-codesandbox:complete -->

author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-05-21T06:51:24Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Hunter87ff_Spruce&pullRequest=80) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=Hunter87ff_Spruce&pullRequest=80&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Hunter87ff_Spruce&pullRequest=80&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Hunter87ff_Spruce&pullRequest=80&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=Hunter87ff_Spruce&pullRequest=80&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Hunter87ff_Spruce&pullRequest=80&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Hunter87ff_Spruce&pullRequest=80)


author_association: NONE