From 75185ba5f92b534050ff2075b35c701388fd780d Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:18:39 +0200 Subject: [PATCH 01/56] Initial commit --- .gitignore | 207 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b7faf403 --- /dev/null +++ b/.gitignore @@ -0,0 +1,207 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ From ab2ec24050b938550c93b19fd365b79b5bd20494 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:17:09 +0200 Subject: [PATCH 02/56] Initial Setup Setup: - Workflow - Pre-Commit - Project Structure - Linting/Formatting/Checking --- .github/workflows/lint.yaml | 35 ++++++++++++ .gitignore | 8 +-- .pre-commit-config.yaml | 38 +++++++++++++ LICENSE.txt | 33 +++++++++++ README.md | 1 + pyproject.toml | 97 +++++++++++++++++++++++++++++++++ requirements.txt | 3 + setup.py | 3 + src/witty_wisterias/__init__.py | 0 src/witty_wisterias/__main__.py | 7 +++ 10 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/lint.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 src/witty_wisterias/__init__.py create mode 100644 src/witty_wisterias/__main__.py diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 00000000..7f67e803 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +# GitHub Action workflow enforcing our code style. + +name: Lint + +# Trigger the workflow on both push (to the main repository, on the main branch) +# and pull requests (against the main repository, but from any repo, from any branch). +on: + push: + branches: + - main + pull_request: + +# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. +# It is useful for pull requests coming from the main repository since both triggers will match. +concurrency: lint-${{ github.sha }} + +jobs: + lint: + runs-on: ubuntu-latest + + env: + # The Python version your project uses. Feel free to change this if required. + PYTHON_VERSION: "3.12" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 diff --git a/.gitignore b/.gitignore index b7faf403..f18571fb 100644 --- a/.gitignore +++ b/.gitignore @@ -173,7 +173,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. @@ -182,11 +182,11 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder -# .vscode/ +.vscode/ # Ruff stuff: .ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..cdecdadd --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +# Pre-commit configuration. +# See https://github.com/python-discord/code-jam-template/tree/main#pre-commit-run-linting-before-committing + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-ast + - id: check-case-conflict + - id: check-docstring-first + - id: check-illegal-windows-names + - id: check-json + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: pretty-format-json + args: [--autofix] + - id: requirements-txt-fixer + + - repo: https://github.com/asottile/pyupgrade + rev: v3.20.0 + hooks: + - id: pyupgrade + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.7 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.17.1 + hooks: + - id: mypy diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..cbd5fc0a --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,33 @@ +MIT License + +Copyright (c) 2025 Witty Wisterias + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +The Software incorporates code from which is under the following license: + +Copyright 2021 Python Discord + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..935351a1 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Witty Wisterias diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..7dd0e47b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,97 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "witty_wisterias" +version = "1.0.0" +description = "Witty Wisterias submission for the Python Discord Summer CodeJam 2025." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [ + { name = "bensgilbert" }, + { name = "erri4" }, + { name = "pedro-alvesjr" }, + { name = "Tails5000" }, + { name = "Vinyzu" }, +] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Typing :: Typed", +] +dynamic = ["dependencies", "optional-dependencies"] + +[tool.setuptools.dynamic] +dependencies = { file = "requirements.txt" } + +[tool.setuptools.package-data] +witty_wisterias = ["py.typed"] + +[project.urls] +Homepage = "https://github.com/WittyWisterias/WittyWisterias" + +[project.scripts] +witty_wisterias = "witty_wisterias.__main__:main" + +[tool.ruff] +# Increase the line length. This breaks PEP8 but it is way easier to work with. +# The original reason for this limit was a standard vim terminal is only 79 characters, +# but this doesn't really apply anymore. +line-length = 119 +# Target Python 3.12. +target-version = "py312" +# Automatically fix auto-fixable issues. +fix = true +# The directory containing the source code. +src = ["src"] + +[tool.ruff.lint] +# Enable all linting rules. +select = ["ALL"] +# Ignore some of the most obnoxious linting errors. +ignore = [ + # Missing docstrings. + "D100", + "D104", + "D105", + "D106", + "D107", + # Docstring whitespace. + "D203", + "D212", + # Docstring punctuation. + "D415", + # Docstring quotes. + "D301", + # Print statements. + "T20", + # TODOs. + "TD002", + "TD003", + "FIX", + # Annotations. + "ANN101", + "ANN102", + # Future annotations. + "FA", + # Error messages. + "EM", +] + +[tool.ruff.lint.pydocstyle] +# Use Google-style docstrings. +convention = "google" +ignore-decorators = ["typing.overload"] + +[tool.mypy] +strict = true +disallow_untyped_decorators = false +disallow_subclassing_any = false diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..a656d11e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pre-commit~=4.2.0 +ruff~=0.12.7 +setuptools~=80.9.0 diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..60684932 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/src/witty_wisterias/__init__.py b/src/witty_wisterias/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/witty_wisterias/__main__.py b/src/witty_wisterias/__main__.py new file mode 100644 index 00000000..8bda12c0 --- /dev/null +++ b/src/witty_wisterias/__main__.py @@ -0,0 +1,7 @@ +def main() -> None: + """Main entry point for the project.""" + return + + +if __name__ == "__main__": + main() From eddf0310608285e88e51a10f3a9f2f9370f0c51e Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 6 Aug 2025 21:50:04 +0200 Subject: [PATCH 03/56] Add Code Review Enforcement - Add review.yaml Should work as expected to Enforce at least one Pull Request Review before Merging --- .github/workflows/review.yaml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/review.yaml diff --git a/.github/workflows/review.yaml b/.github/workflows/review.yaml new file mode 100644 index 00000000..3b527e84 --- /dev/null +++ b/.github/workflows/review.yaml @@ -0,0 +1,23 @@ +name: Enforce One Required Pull Request Approval + +on: + pull_request: + branches: + - main + pull_request_review: + types: [submitted] + +jobs: + check-approvals: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + steps: + - name: Require approval from at least one reviewer + uses: peternied/required-approval@v1.3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + required-approvers-list: "bensgilbert,erri4,pedro-alvesjr,Tails5000,Vinyzu" + min-required: 1 From 5e6db041194c56002a09e33136de75d09b158746 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 6 Aug 2025 22:17:55 +0200 Subject: [PATCH 04/56] Add comment to Pull Request to show Approval is needed --- .github/workflows/review.yaml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/review.yaml b/.github/workflows/review.yaml index 3b527e84..d2c67d09 100644 --- a/.github/workflows/review.yaml +++ b/.github/workflows/review.yaml @@ -16,8 +16,31 @@ jobs: pull-requests: read steps: - name: Require approval from at least one reviewer + id: approval_check uses: peternied/required-approval@v1.3 with: token: ${{ secrets.GITHUB_TOKEN }} required-approvers-list: "bensgilbert,erri4,pedro-alvesjr,Tails5000,Vinyzu" min-required: 1 + + - name: Check if reminder comment exists + if: steps.approval_check.outputs.specific-approvals == '' + id: find_comment + uses: peter-evans/find-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: github-actions[bot] + body-includes: "Waiting for at least one more approval" + + - name: Add reminder comment if missing + if: steps.approval_check.outputs.specific-approvals == '' && steps.find_comment.outputs.comment-id == '' + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + ⏳ **Waiting for at least one more approval** from: + - @bensgilbert + - @erri4 + - @pedro-alvesjr + - @tails5000 + - @vinyzu From 83a6006a8b240e4eb0171bf33cd1c080ff0cfc13 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 6 Aug 2025 22:21:27 +0200 Subject: [PATCH 05/56] Make Sure Message is sent if Workflow fails --- .github/workflows/review.yaml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/review.yaml b/.github/workflows/review.yaml index d2c67d09..d7e33847 100644 --- a/.github/workflows/review.yaml +++ b/.github/workflows/review.yaml @@ -10,6 +10,8 @@ on: jobs: check-approvals: runs-on: ubuntu-latest + outputs: + has_approval: ${{ steps.approval_check.outputs.specific-approvals }} permissions: id-token: write contents: read @@ -22,9 +24,16 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} required-approvers-list: "bensgilbert,erri4,pedro-alvesjr,Tails5000,Vinyzu" min-required: 1 + continue-on-error: true + comment-if-missing: + runs-on: ubuntu-latest + needs: approval-check + if: needs.approval-check.outputs.has_approval == '' + permissions: + pull-requests: write + steps: - name: Check if reminder comment exists - if: steps.approval_check.outputs.specific-approvals == '' id: find_comment uses: peter-evans/find-comment@v3 with: @@ -33,7 +42,7 @@ jobs: body-includes: "Waiting for at least one more approval" - name: Add reminder comment if missing - if: steps.approval_check.outputs.specific-approvals == '' && steps.find_comment.outputs.comment-id == '' + if: steps.find_comment.outputs.comment-id == '' uses: peter-evans/create-or-update-comment@v4 with: issue-number: ${{ github.event.pull_request.number }} From b3c83975602613611d555fbb6e8b84db9ec6c5bc Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 6 Aug 2025 22:24:48 +0200 Subject: [PATCH 06/56] Make Sure Action is executed --- .github/workflows/review.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/review.yaml b/.github/workflows/review.yaml index d7e33847..f0d73620 100644 --- a/.github/workflows/review.yaml +++ b/.github/workflows/review.yaml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + types: [opened, synchronize, reopened, ready_for_review] pull_request_review: types: [submitted] @@ -28,8 +29,8 @@ jobs: comment-if-missing: runs-on: ubuntu-latest - needs: approval-check - if: needs.approval-check.outputs.has_approval == '' + needs: check-approvals + if: needs.check-approvals.outputs.has_approval == '' permissions: pull-requests: write steps: From e4c0bb6a509418357636ad783eda34de4b03ee7b Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 6 Aug 2025 22:28:06 +0200 Subject: [PATCH 07/56] Fail the workflow to make sure Merging is blocked --- .github/workflows/review.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/review.yaml b/.github/workflows/review.yaml index f0d73620..e610dfc3 100644 --- a/.github/workflows/review.yaml +++ b/.github/workflows/review.yaml @@ -54,3 +54,13 @@ jobs: - @pedro-alvesjr - @tails5000 - @vinyzu + + fail-if-missing: + runs-on: ubuntu-latest + needs: [check-approvals, comment-if-missing] + if: needs.check-approvals.outputs.has_approval == '' + steps: + - name: Fail the workflow if missing approval + run: | + echo "❌ Approval missing: at least one of the required users must approve." + exit 1 From cf6d1db3da730638bb9bdb495c8a1cfb9a4ad4cf Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:49:31 +0200 Subject: [PATCH 08/56] Improve Approval Checking - Remove Bot Comment is Approval is present - Make sure Action succeeds if approval is found - Move all to one Job for better Clarity in PR --- .github/workflows/review.yaml | 43 +++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/.github/workflows/review.yaml b/.github/workflows/review.yaml index e610dfc3..d86e0160 100644 --- a/.github/workflows/review.yaml +++ b/.github/workflows/review.yaml @@ -11,12 +11,10 @@ on: jobs: check-approvals: runs-on: ubuntu-latest - outputs: - has_approval: ${{ steps.approval_check.outputs.specific-approvals }} permissions: id-token: write contents: read - pull-requests: read + pull-requests: write # needed for commenting steps: - name: Require approval from at least one reviewer id: approval_check @@ -27,23 +25,17 @@ jobs: min-required: 1 continue-on-error: true - comment-if-missing: - runs-on: ubuntu-latest - needs: check-approvals - if: needs.check-approvals.outputs.has_approval == '' - permissions: - pull-requests: write - steps: - - name: Check if reminder comment exists - id: find_comment + - name: Add reminder comment if approval is missing + if: steps.approval_check.outputs.specific-approvals == '' + id: find_comment_missing uses: peter-evans/find-comment@v3 with: issue-number: ${{ github.event.pull_request.number }} comment-author: github-actions[bot] body-includes: "Waiting for at least one more approval" - - name: Add reminder comment if missing - if: steps.find_comment.outputs.comment-id == '' + - name: Post reminder comment + if: steps.approval_check.outputs.specific-approvals == '' && steps.find_comment_missing.outputs.comment-id == '' uses: peter-evans/create-or-update-comment@v4 with: issue-number: ${{ github.event.pull_request.number }} @@ -55,12 +47,23 @@ jobs: - @tails5000 - @vinyzu - fail-if-missing: - runs-on: ubuntu-latest - needs: [check-approvals, comment-if-missing] - if: needs.check-approvals.outputs.has_approval == '' - steps: - - name: Fail the workflow if missing approval + - name: Delete reminder comment if approval is now present + if: steps.approval_check.outputs.specific-approvals != '' + id: find_comment_present + uses: peter-evans/find-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: github-actions[bot] + body-includes: "Waiting for at least one more approval" + + - name: Remove comment + if: steps.find_comment_present.outputs.comment-id != '' + uses: peter-evans/delete-comment@v3 + with: + comment-id: ${{ steps.find_comment_present.outputs.comment-id }} + + - name: Fail workflow if approval is missing + if: steps.approval_check.outputs.specific-approvals == '' run: | echo "❌ Approval missing: at least one of the required users must approve." exit 1 From 48e625ba376c0a46f3cebcf192b84d5288f6ae33 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:51:15 +0200 Subject: [PATCH 09/56] Move to actions/github-script to Delete Comment --- .github/workflows/review.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/review.yaml b/.github/workflows/review.yaml index d86e0160..4e479c87 100644 --- a/.github/workflows/review.yaml +++ b/.github/workflows/review.yaml @@ -56,11 +56,17 @@ jobs: comment-author: github-actions[bot] body-includes: "Waiting for at least one more approval" - - name: Remove comment + - name: Delete reminder comment if: steps.find_comment_present.outputs.comment-id != '' - uses: peter-evans/delete-comment@v3 + uses: actions/github-script@v6 with: - comment-id: ${{ steps.find_comment_present.outputs.comment-id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: parseInt("${{ steps.find_comment_present.outputs.comment-id }}", 10) + }); - name: Fail workflow if approval is missing if: steps.approval_check.outputs.specific-approvals == '' From fb36d95021a720f1817946e924c35ff75feeb4c8 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:21:29 +0200 Subject: [PATCH 10/56] Improve Approval Handling - Check for Draft PRs - Approval Output Flag Check works now - Better Commenting/Naming --- .github/workflows/review.yaml | 36 ++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/review.yaml b/.github/workflows/review.yaml index 4e479c87..b0b619b1 100644 --- a/.github/workflows/review.yaml +++ b/.github/workflows/review.yaml @@ -1,4 +1,4 @@ -name: Enforce One Required Pull Request Approval +name: 🛡️ Require one Pull Request Approval on: pull_request: @@ -6,27 +6,37 @@ on: - main types: [opened, synchronize, reopened, ready_for_review] pull_request_review: + # Also recheck when a review is submitted to automatically allow merging types: [submitted] jobs: - check-approvals: + approval-check: + name: "​" # NOTE: Were using a zero-width space to save some visial clutter in the GitHub Pull Request UI + if: github.event.pull_request.draft == false # Only run if the PR is not a draft + runs-on: ubuntu-latest permissions: id-token: write contents: read - pull-requests: write # needed for commenting + pull-requests: write + steps: - name: Require approval from at least one reviewer id: approval_check uses: peternied/required-approval@v1.3 with: token: ${{ secrets.GITHUB_TOKEN }} - required-approvers-list: "bensgilbert,erri4,pedro-alvesjr,Tails5000,Vinyzu" + required-approvers-list: "bensgilbert,erri4,pedro-alvesjr,Tails5000,Vinyzu" # GitHub username is case-sensitive min-required: 1 continue-on-error: true - - name: Add reminder comment if approval is missing - if: steps.approval_check.outputs.specific-approvals == '' + - name: Set approval flag # This is a workarround because peternied/required-approval's output do not work, so we are checking the stdout + id: set_approved_flag + run: | + echo "approved=$(echo '${{ steps.approval_check.outcome }}' | grep -q 'success' && echo 'yes' || echo 'no')" >> $GITHUB_OUTPUT + + - name: Add reminder comment if approval is missing # Checking if approval comment was already posted + if: steps.set_approved_flag.outputs.approved == 'no' id: find_comment_missing uses: peter-evans/find-comment@v3 with: @@ -34,8 +44,8 @@ jobs: comment-author: github-actions[bot] body-includes: "Waiting for at least one more approval" - - name: Post reminder comment - if: steps.approval_check.outputs.specific-approvals == '' && steps.find_comment_missing.outputs.comment-id == '' + - name: Add reminder comment if missing # Add approval comment if it was not posted yet + if: steps.set_approved_flag.outputs.approved == 'no' && steps.find_comment_missing.outputs.comment-id == '' uses: peter-evans/create-or-update-comment@v4 with: issue-number: ${{ github.event.pull_request.number }} @@ -47,8 +57,8 @@ jobs: - @tails5000 - @vinyzu - - name: Delete reminder comment if approval is now present - if: steps.approval_check.outputs.specific-approvals != '' + - name: Delete reminder comment if approval is now present # Find approval comment if it was posted + if: steps.set_approved_flag.outputs.approved == 'yes' id: find_comment_present uses: peter-evans/find-comment@v3 with: @@ -56,7 +66,7 @@ jobs: comment-author: github-actions[bot] body-includes: "Waiting for at least one more approval" - - name: Delete reminder comment + - name: Delete reminder comment if approval is now present # Delete approval comment if it was posted and approval is now present if: steps.find_comment_present.outputs.comment-id != '' uses: actions/github-script@v6 with: @@ -68,8 +78,8 @@ jobs: comment_id: parseInt("${{ steps.find_comment_present.outputs.comment-id }}", 10) }); - - name: Fail workflow if approval is missing - if: steps.approval_check.outputs.specific-approvals == '' + - name: Fail workflow if approval is missing # Fail the workflow if approval is still missing + if: steps.set_approved_flag.outputs.approved == 'no' run: | echo "❌ Approval missing: at least one of the required users must approve." exit 1 From ecc7f10e2e7c41dc88f163327846eedfacae50b3 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sat, 9 Aug 2025 09:18:08 +0200 Subject: [PATCH 11/56] Add Image Encoding Functionality Fixes https://github.com/WittyWisterias/WittyWisterias/issues/6 Probably not that space-efficient (yet), but good for now. --- .gitignore | 3 ++ pyproject.toml | 6 +-- src/witty_wisterias/modules/__init__.py | 0 src/witty_wisterias/modules/database.py | 61 +++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/witty_wisterias/modules/__init__.py create mode 100644 src/witty_wisterias/modules/database.py diff --git a/.gitignore b/.gitignore index f18571fb..9299eb99 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,6 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# DS Store (macOS) +.DS_Store diff --git a/pyproject.toml b/pyproject.toml index 7dd0e47b..f1230c80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ ignore = [ "D107", # Docstring whitespace. "D203", + "D205", "D212", # Docstring punctuation. "D415", @@ -77,13 +78,12 @@ ignore = [ "TD002", "TD003", "FIX", - # Annotations. - "ANN101", - "ANN102", # Future annotations. "FA", # Error messages. "EM", + # Path/Opens + "PTH123" ] [tool.ruff.lint.pydocstyle] diff --git a/src/witty_wisterias/modules/__init__.py b/src/witty_wisterias/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/witty_wisterias/modules/database.py b/src/witty_wisterias/modules/database.py new file mode 100644 index 00000000..20534928 --- /dev/null +++ b/src/witty_wisterias/modules/database.py @@ -0,0 +1,61 @@ +import base64 +import math +from io import BytesIO + +from PIL import Image + + +class Database: + """ + Our Database, responsible for storing and retrieving data. + We are encoding any data into .PNG Images (lossless compression). + Then we will store them here: https://freeimghost.net, which is a free image hosting service. + We will later be able to query for the latest messages via https://freeimghost.net/search/images/?q={SearchTerm} + """ + + def __init__(self) -> None: + pass + + @staticmethod + def base64_to_image(base64_string: str) -> bytes: + """ + Converts arbitrary base64 encoded data to image bytes. + + Args: + base64_string (str): The base64 encoded arbitrary data. + + Returns: + bytes: The encoded data as image bytes. + """ + # Decode the base64 string + data = base64.b64decode(base64_string) + # Prepend Custom Message Header for later Image Validation + header = b"WittyWisterias" + validation_data = header + data + + # Check how many total pixels we need + total_pixels = math.ceil(len(validation_data) / 3) + # Calculate the size of the image (assuming square for simplicity) + # TODO: Use a more complex, space-efficient shape to save more data if needed + size = math.ceil(math.sqrt(total_pixels)) + # Pad the data to fit the image size + padded_data = validation_data.ljust(size * size * 3, b"\x00") + + # Create the image bytes from the padded data + pil_image = Image.frombytes(mode="RGB", size=(size, size), data=padded_data) + # Save as PNG (lossless) in memory + buffer = BytesIO() + pil_image.save(buffer, format="PNG") + return buffer.getvalue() + + +if __name__ == "__main__": + # Example/Testing usage + db = Database() + # Base64 for "Hello, World!" + base64_string = "SGVsbG8sIFdvcmxkIQ==" + image_bytes = db.base64_to_image(base64_string) + # Save the image bytes to a file for validation/verification + with open("output_image.png", "wb") as f: + f.write(image_bytes) + print("Image created successfully.") From ca14f3b46365271b990a4e50f9f62b2e9119481b Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sat, 9 Aug 2025 11:00:50 +0200 Subject: [PATCH 12/56] Add Functionality to Upload Images/Data to Hoster Fixes https://github.com/WittyWisterias/WittyWisterias/issues/7 --- .pre-commit-config.yaml | 1 - pyproject.toml | 15 +++- requirements.txt | 6 ++ src/witty_wisterias/modules/database.py | 101 +++++++++++++++++++--- src/witty_wisterias/modules/exceptions.py | 2 + 5 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 src/witty_wisterias/modules/exceptions.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cdecdadd..891efd42 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,6 @@ repos: args: [--markdown-linebreak-ext=md] - id: pretty-format-json args: [--autofix] - - id: requirements-txt-fixer - repo: https://github.com/asottile/pyupgrade rev: v3.20.0 diff --git a/pyproject.toml b/pyproject.toml index f1230c80..35e014aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,10 @@ target-version = "py312" fix = true # The directory containing the source code. src = ["src"] +exclude = [ + # Want to keep Formatting in the requirements file. + "requirements.txt", +] [tool.ruff.lint] # Enable all linting rules. @@ -82,8 +86,17 @@ ignore = [ "FA", # Error messages. "EM", + "TRY003", # Path/Opens - "PTH123" + "PTH123", + # Trailing commas + "COM812", + # Magic Values + "PLR2004", + # Pseudo-random numbers + "S311", + # Hardcoded Password + "S105" ] [tool.ruff.lint.pydocstyle] diff --git a/requirements.txt b/requirements.txt index a656d11e..ee74f0bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,9 @@ +# Setup requirements for the project pre-commit~=4.2.0 ruff~=0.12.7 setuptools~=80.9.0 +types-requests +# Project dependencies +httpx~=0.28.1 +pillow~=11.3.0 +xxhash~=3.5.0 diff --git a/src/witty_wisterias/modules/database.py b/src/witty_wisterias/modules/database.py index 20534928..55a6ec1f 100644 --- a/src/witty_wisterias/modules/database.py +++ b/src/witty_wisterias/modules/database.py @@ -1,9 +1,17 @@ import base64 import math +import random +import re +from datetime import UTC, datetime from io import BytesIO +# Using httpx instead of requests as it is more modern and has built-in typing support +import httpx +import xxhash from PIL import Image +from .exceptions import InvalidResponseError + class Database: """ @@ -14,23 +22,24 @@ class Database: """ def __init__(self) -> None: - pass + self.session = httpx.Client() @staticmethod - def base64_to_image(base64_string: str) -> bytes: + def base64_to_image(base64_data: str) -> bytes: """ Converts arbitrary base64 encoded data to image bytes. Args: - base64_string (str): The base64 encoded arbitrary data. + base64_data (str): The base64 encoded arbitrary data. Returns: bytes: The encoded data as image bytes. """ # Decode the base64 string - data = base64.b64decode(base64_string) + data = base64.b64decode(base64_data) # Prepend Custom Message Header for later Image Validation - header = b"WittyWisterias" + # We also add a random noise header to avoid duplicates + header = b"WittyWisterias" + random.randbytes(8) validation_data = header + data # Check how many total pixels we need @@ -48,14 +57,86 @@ def base64_to_image(base64_string: str) -> bytes: pil_image.save(buffer, format="PNG") return buffer.getvalue() + def get_configuration_data(self) -> str: + """ + Fetches the necessary configuration data for uploading images to the database. + + Returns: + str: The auth token required for uploading images. + + Raises: + AssertionError: If the configuration data cannot be fetched or the auth token is not found. + """ + # Getting necessary configuration data for upload + config_response = self.session.get("https://freeimghost.net/upload") + # Check if the response is successful + if config_response.status_code != 200: + raise InvalidResponseError("Failed to fetch configuration data from the image hosting service.") + + # Getting auth token from config response + auth_token_pattern = r'PF\.obj\.config\.auth_token\s*=\s*"([a-fA-F0-9]{40})";' + match = re.search(auth_token_pattern, config_response.text) + if not match: + raise InvalidResponseError("Auth token not found in the configuration response.") + # Extracting auth token + return match.group(1) + + def upload_image(self, image_bytes: bytes) -> None: + """ + Uploads the image bytes to the database and returns the URL. + + Args: + image_bytes (bytes): The image bytes to upload. + + Raises: + AssertionError: If the upload fails or the response is not as expected. + """ + # Get current UTC time + utc_time = datetime.now(UTC) + # Convert to UTC Timestamp + utc_timestamp = utc_time.timestamp() + + auth_token = self.get_configuration_data() + # Hash the image bytes to create a checksum using xxHash64 (Specified by Image Hosting Service) + checksum = xxhash.xxh64(image_bytes).hexdigest() + + # Post Image to Image Hosting Service + response = self.session.post( + url="https://freeimghost.net/json", + files={ + "source": (f"WittyWisterias_{utc_timestamp}.png", image_bytes, "image/png"), + }, + data={ + "type": "file", + "action": "upload", + "timestamp": str(int(utc_timestamp)), + "auth_token": auth_token, + "nsfw": "0", + "mimetype": "image/png", + "checksum": checksum, + }, + ) + # Check if the response is successful + if response.status_code != 200: + raise InvalidResponseError("Failed to upload image to the image hosting service.") + + def upload_data(self, base64_data: str) -> None: + """ + Uploads base64 encoded data as an image to the database hosted on the Image Hosting Service. + + Args: + base64_data (str): The base64 encoded data to upload. + + Raises: + AssertionError: If the upload fails or the response is not as expected. + """ + image_bytes = self.base64_to_image(base64_data) + self.upload_image(image_bytes) + if __name__ == "__main__": # Example/Testing usage db = Database() # Base64 for "Hello, World!" base64_string = "SGVsbG8sIFdvcmxkIQ==" - image_bytes = db.base64_to_image(base64_string) - # Save the image bytes to a file for validation/verification - with open("output_image.png", "wb") as f: - f.write(image_bytes) - print("Image created successfully.") + db.upload_data(base64_string) diff --git a/src/witty_wisterias/modules/exceptions.py b/src/witty_wisterias/modules/exceptions.py new file mode 100644 index 00000000..9dc5f0e8 --- /dev/null +++ b/src/witty_wisterias/modules/exceptions.py @@ -0,0 +1,2 @@ +class InvalidResponseError(Exception): + """Raise for invalid responses from the server.""" From 3f5fee944f9cb33150831be36b7cd1593fe9ccfb Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sat, 9 Aug 2025 11:54:32 +0200 Subject: [PATCH 13/56] Add Query to search for latest Data in Fixes https://github.com/WittyWisterias/WittyWisterias/issues/8 Fixes https://github.com/WittyWisterias/WittyWisterias/issues/9 --- pyproject.toml | 4 +- requirements.txt | 1 + src/witty_wisterias/modules/database.py | 74 +++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35e014aa..8586f74f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,9 @@ ignore = [ # Pseudo-random numbers "S311", # Hardcoded Password - "S105" + "S105", + # Commented-out code + "ERA001", ] [tool.ruff.lint.pydocstyle] diff --git a/requirements.txt b/requirements.txt index ee74f0bc..1256d1d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ ruff~=0.12.7 setuptools~=80.9.0 types-requests # Project dependencies +beautifulsoup4~=4.13.4 httpx~=0.28.1 pillow~=11.3.0 xxhash~=3.5.0 diff --git a/src/witty_wisterias/modules/database.py b/src/witty_wisterias/modules/database.py index 55a6ec1f..71cb378f 100644 --- a/src/witty_wisterias/modules/database.py +++ b/src/witty_wisterias/modules/database.py @@ -8,6 +8,7 @@ # Using httpx instead of requests as it is more modern and has built-in typing support import httpx import xxhash +from bs4 import BeautifulSoup from PIL import Image from .exceptions import InvalidResponseError @@ -24,6 +25,23 @@ class Database: def __init__(self) -> None: self.session = httpx.Client() + @staticmethod + def extract_timestamp(url: str) -> float: + """ + Extracts the timestamp from the Filename of a given URL in our format. + + Args: + url (str): The URL from which to extract the timestamp. + + Returns: + float: The extracted timestamp as a float. + """ + # Use regex to find the timestamp in the URL + match = re.search(r"(\d+\.\d+)", url) + if match: + return float(match.group(1)) + return 0.0 + @staticmethod def base64_to_image(base64_data: str) -> bytes: """ @@ -65,7 +83,7 @@ def get_configuration_data(self) -> str: str: The auth token required for uploading images. Raises: - AssertionError: If the configuration data cannot be fetched or the auth token is not found. + InvalidResponseError: If the configuration data cannot be fetched or the auth token is not found. """ # Getting necessary configuration data for upload config_response = self.session.get("https://freeimghost.net/upload") @@ -89,7 +107,7 @@ def upload_image(self, image_bytes: bytes) -> None: image_bytes (bytes): The image bytes to upload. Raises: - AssertionError: If the upload fails or the response is not as expected. + InvalidResponseError: If the upload fails or the response is not as expected. """ # Get current UTC time utc_time = datetime.now(UTC) @@ -120,6 +138,53 @@ def upload_image(self, image_bytes: bytes) -> None: if response.status_code != 200: raise InvalidResponseError("Failed to upload image to the image hosting service.") + def query_data(self) -> str: + """ + Queries the latest data from the database. + + Returns: + str: The latest data. + + Raises: + InvalidResponseError: If the query fails or the response is not as expected. + """ + # Query all images with the search term "WittyWisterias" from the image hosting service + response = self.session.get("https://freeimghost.net/search/images/?q=WittyWisterias") + # Check if the response is successful + if response.status_code != 200: + raise InvalidResponseError("Failed to query latest image from the image hosting service.") + + # Extracting the latest image URL from the response using beautifulsoup + soup = BeautifulSoup(response.text, "html.parser") + # Find all image elements which are hosted on the image hosting service + image_links = [img.get("src") for img in soup.find_all("img") if "https://freeimghost.net/" in img.get("src")] + + # Sort the image elements by the timestamp in the filename (in the link) (newest first) + sorted_image_links = sorted(image_links, key=self.extract_timestamp, reverse=True) + + # Find the first image link that contains our validation header and return its pixel byte data + for image_link in sorted_image_links: + # Fetch the image content + image_content = self.session.get(image_link).content + + # Get the byte content of the image without the PNG Image File Header + image_stream = BytesIO(image_content) + pil_image = Image.open(image_stream).convert("RGB") + pixel_byte_data = pil_image.tobytes() + + # Validate the image content starts with our validation header + if pixel_byte_data.startswith(b"WittyWisterias"): + # Remove the validation header and noise bytes from the first valid image + no_header_data = pixel_byte_data[len(b"WittyWisterias") + 8 :] + # Remove any padding bytes (if any) to get the original data + no_padding_data = no_header_data.rstrip(b"\x00") + # Decode bytes into string and return it + decoded_data: str = no_padding_data.decode("utf-8", errors="ignore") + return decoded_data + + # If no valid image is found, raise an error + raise InvalidResponseError("No valid image found in the response.") + def upload_data(self, base64_data: str) -> None: """ Uploads base64 encoded data as an image to the database hosted on the Image Hosting Service. @@ -128,7 +193,7 @@ def upload_data(self, base64_data: str) -> None: base64_data (str): The base64 encoded data to upload. Raises: - AssertionError: If the upload fails or the response is not as expected. + InvalidResponseError: If the upload fails or the response is not as expected. """ image_bytes = self.base64_to_image(base64_data) self.upload_image(image_bytes) @@ -138,5 +203,6 @@ def upload_data(self, base64_data: str) -> None: # Example/Testing usage db = Database() # Base64 for "Hello, World!" - base64_string = "SGVsbG8sIFdvcmxkIQ==" + base64_string = base64.b64encode(b"Hello, World! Witty Wisterias.").decode("utf-8") db.upload_data(base64_string) + print(db.query_data()) From 91e3d33ac2077b7dd9d05ecb4e57b07f9ce76d90 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sat, 9 Aug 2025 11:58:12 +0200 Subject: [PATCH 14/56] Move from Base64 to String Encoding --- src/witty_wisterias/modules/database.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/witty_wisterias/modules/database.py b/src/witty_wisterias/modules/database.py index 71cb378f..7bdf8e71 100644 --- a/src/witty_wisterias/modules/database.py +++ b/src/witty_wisterias/modules/database.py @@ -1,4 +1,3 @@ -import base64 import math import random import re @@ -43,18 +42,16 @@ def extract_timestamp(url: str) -> float: return 0.0 @staticmethod - def base64_to_image(base64_data: str) -> bytes: + def base64_to_image(data: bytes) -> bytes: """ - Converts arbitrary base64 encoded data to image bytes. + Converts arbitrary byte encoded data to image bytes. Args: - base64_data (str): The base64 encoded arbitrary data. + data (bytes): The byte encoded arbitrary data. Returns: bytes: The encoded data as image bytes. """ - # Decode the base64 string - data = base64.b64decode(base64_data) # Prepend Custom Message Header for later Image Validation # We also add a random noise header to avoid duplicates header = b"WittyWisterias" + random.randbytes(8) @@ -185,24 +182,26 @@ def query_data(self) -> str: # If no valid image is found, raise an error raise InvalidResponseError("No valid image found in the response.") - def upload_data(self, base64_data: str) -> None: + def upload_data(self, data: str) -> None: """ - Uploads base64 encoded data as an image to the database hosted on the Image Hosting Service. + Uploads string encoded data as an image to the database hosted on the Image Hosting Service. Args: - base64_data (str): The base64 encoded data to upload. + data (str): The data to upload, encoded in a string. Raises: InvalidResponseError: If the upload fails or the response is not as expected. """ - image_bytes = self.base64_to_image(base64_data) + # Convert the string data to bytes + bytes_data = data.encode("utf-8") + # Convert the bytes data to an Image which contains encoded data + image_bytes = self.base64_to_image(bytes_data) + # Upload the image bytes to the Image Hosting Service self.upload_image(image_bytes) if __name__ == "__main__": # Example/Testing usage db = Database() - # Base64 for "Hello, World!" - base64_string = base64.b64encode(b"Hello, World! Witty Wisterias.").decode("utf-8") - db.upload_data(base64_string) + db.upload_data("Hello, World! Witty Wisterias here.") print(db.query_data()) From f799b008269ce9cc2578c3e87f299013519ec05e Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sat, 9 Aug 2025 14:23:26 +0200 Subject: [PATCH 15/56] Fix Review Workflow so merging is possible after review --- .github/workflows/review.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/review.yaml b/.github/workflows/review.yaml index b0b619b1..92e19fce 100644 --- a/.github/workflows/review.yaml +++ b/.github/workflows/review.yaml @@ -9,9 +9,13 @@ on: # Also recheck when a review is submitted to automatically allow merging types: [submitted] +concurrency: # Make sure only one approval check runs at a time for the same PR, so the pull_request workflow doesnt fail after a review is submitted + group: approval-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: approval-check: - name: "​" # NOTE: Were using a zero-width space to save some visial clutter in the GitHub Pull Request UI + name: "​" # NOTE: Were using a zero-width space to save some visual clutter in the GitHub Pull Request UI if: github.event.pull_request.draft == false # Only run if the PR is not a draft runs-on: ubuntu-latest From 560f2ac8c19e5e1caa02cf42f4f8000b78d75552 Mon Sep 17 00:00:00 2001 From: Pedro Alves Date: Sat, 9 Aug 2025 21:16:16 -0300 Subject: [PATCH 16/56] Create format.py Define message format (Task 27) --- src/witty_wisterias/message_format/format.py | 65 ++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/witty_wisterias/message_format/format.py diff --git a/src/witty_wisterias/message_format/format.py b/src/witty_wisterias/message_format/format.py new file mode 100644 index 00000000..b9b2eb93 --- /dev/null +++ b/src/witty_wisterias/message_format/format.py @@ -0,0 +1,65 @@ +import json +from typing import Any, Dict, Optional + + +class MessageFormat: + """ + Defines the standard structure for messages in the system. + Supports serialization/deserialization for storage in images. + """ + + def __init__( + self, + sender_id: str, + content: Any, + event_type: str, + receiver_id: Optional[str] = None, + public_key: Optional[str] = None, + extra_event_info: Optional[Dict] = None, + previous_messages: Optional[list] = None, + stop_signal: bool = False + ): + self.sender_id = sender_id + self.receiver_id = receiver_id + self.event_type = event_type + self.public_key = public_key + self.content = content + self.extra_event_info = extra_event_info or {} + self.previous_messages = previous_messages or [] + self.stop_signal = stop_signal + + def to_dict(self) -> Dict: + """Convert the message into a Python dictionary.""" + return { + "header": { + "sender_id": self.sender_id, + "receiver_id": self.receiver_id, + "event_type": self.event_type, + "public_key": self.public_key + }, + "body": { + "content": self.content, + "extra_event_info": self.extra_event_info + }, + "previous_messages": self.previous_messages, + "stop_signal": self.stop_signal + } + + def to_json(self) -> str: + """Serialize the message into a JSON string.""" + return json.dumps(self.to_dict(), ensure_ascii=False) + + @staticmethod + def from_json(data: str) -> "MessageFormat": + """Deserialize a JSON string into a MessageFormat object.""" + obj = json.loads(data) + return MessageFormat( + sender_id=obj["header"]["sender_id"], + receiver_id=obj["header"].get("receiver_id"), + event_type=obj["header"]["event_type"], + public_key=obj["header"].get("public_key"), + content=obj["body"]["content"], + extra_event_info=obj["body"].get("extra_event_info", {}), + previous_messages=obj.get("previous_messages", []), + stop_signal=obj.get("stop_signal", False) + ) \ No newline at end of file From 07fb090d7a3edfcac4937a60a45ed7a1a1df742d Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sun, 10 Aug 2025 09:19:26 +0200 Subject: [PATCH 17/56] Improve Database Data Handling - Raises Error if File Size exceeds 20mb - Chooses the most efficient rectangle for image dimensions (rather than a square) for space efficiency - Move Constant Terms to Class Values for better readability Co-Authored-By: Erri4 <118684880+erri4@users.noreply.github.com> --- src/witty_wisterias/modules/database.py | 50 ++++++++++++++++--------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/witty_wisterias/modules/database.py b/src/witty_wisterias/modules/database.py index 7bdf8e71..8846edbc 100644 --- a/src/witty_wisterias/modules/database.py +++ b/src/witty_wisterias/modules/database.py @@ -21,6 +21,14 @@ class Database: We will later be able to query for the latest messages via https://freeimghost.net/search/images/?q={SearchTerm} """ + # Image Hoster URL and API Endpoints + IMAGE_HOSTER = "https://freeimghost.net" + CONFIG_URL = IMAGE_HOSTER + "/upload" + UPLOAD_URL = IMAGE_HOSTER + "/json" + QUERY_SEARCH_URL = IMAGE_HOSTER + "/search/images/?q=" + # Search Term used to query for our images (and name our files) + FILE_SEARCH_TERM = "WittyWisterias" + def __init__(self) -> None: self.session = httpx.Client() @@ -41,8 +49,7 @@ def extract_timestamp(url: str) -> float: return float(match.group(1)) return 0.0 - @staticmethod - def base64_to_image(data: bytes) -> bytes: + def base64_to_image(self, data: bytes) -> bytes: """ Converts arbitrary byte encoded data to image bytes. @@ -51,26 +58,34 @@ def base64_to_image(data: bytes) -> bytes: Returns: bytes: The encoded data as image bytes. + + Raises: + ValueError: If the resulting image exceeds the size limit of 20MB. """ # Prepend Custom Message Header for later Image Validation # We also add a random noise header to avoid duplicates - header = b"WittyWisterias" + random.randbytes(8) + header = self.FILE_SEARCH_TERM.encode() + random.randbytes(8) validation_data = header + data # Check how many total pixels we need total_pixels = math.ceil(len(validation_data) / 3) - # Calculate the size of the image (assuming square for simplicity) - # TODO: Use a more complex, space-efficient shape to save more data if needed - size = math.ceil(math.sqrt(total_pixels)) + # Calculate the size of the image (using ideal rectangle dimensions for space efficiency) + width = math.ceil(math.sqrt(total_pixels)) + height = math.ceil(total_pixels / width) # Pad the data to fit the image size - padded_data = validation_data.ljust(size * size * 3, b"\x00") + padded_data = validation_data.ljust(width * height * 3, b"\x00") # Create the image bytes from the padded data - pil_image = Image.frombytes(mode="RGB", size=(size, size), data=padded_data) + pil_image = Image.frombytes(mode="RGB", size=(width, height), data=padded_data) # Save as PNG (lossless) in memory buffer = BytesIO() pil_image.save(buffer, format="PNG") - return buffer.getvalue() + # Get the byte content of the image file + image_bytes = buffer.getvalue() + # Check File Size (Image Hosting Service Limit) + if len(image_bytes) > 20 * 1024 * 1024: + raise ValueError("File Size exceeds limit of 20MB, shrink the Image Stack.") + return image_bytes def get_configuration_data(self) -> str: """ @@ -83,7 +98,7 @@ def get_configuration_data(self) -> str: InvalidResponseError: If the configuration data cannot be fetched or the auth token is not found. """ # Getting necessary configuration data for upload - config_response = self.session.get("https://freeimghost.net/upload") + config_response = self.session.get(self.CONFIG_URL) # Check if the response is successful if config_response.status_code != 200: raise InvalidResponseError("Failed to fetch configuration data from the image hosting service.") @@ -98,7 +113,7 @@ def get_configuration_data(self) -> str: def upload_image(self, image_bytes: bytes) -> None: """ - Uploads the image bytes to the database and returns the URL. + Uploads the image bytes to the Database/Image Hosting Service. Args: image_bytes (bytes): The image bytes to upload. @@ -117,9 +132,9 @@ def upload_image(self, image_bytes: bytes) -> None: # Post Image to Image Hosting Service response = self.session.post( - url="https://freeimghost.net/json", + url=self.UPLOAD_URL, files={ - "source": (f"WittyWisterias_{utc_timestamp}.png", image_bytes, "image/png"), + "source": (f"{self.FILE_SEARCH_TERM}_{utc_timestamp}.png", image_bytes, "image/png"), }, data={ "type": "file", @@ -146,7 +161,7 @@ def query_data(self) -> str: InvalidResponseError: If the query fails or the response is not as expected. """ # Query all images with the search term "WittyWisterias" from the image hosting service - response = self.session.get("https://freeimghost.net/search/images/?q=WittyWisterias") + response = self.session.get(self.QUERY_SEARCH_URL + self.FILE_SEARCH_TERM) # Check if the response is successful if response.status_code != 200: raise InvalidResponseError("Failed to query latest image from the image hosting service.") @@ -154,7 +169,7 @@ def query_data(self) -> str: # Extracting the latest image URL from the response using beautifulsoup soup = BeautifulSoup(response.text, "html.parser") # Find all image elements which are hosted on the image hosting service - image_links = [img.get("src") for img in soup.find_all("img") if "https://freeimghost.net/" in img.get("src")] + image_links = [img.get("src") for img in soup.find_all("img") if self.IMAGE_HOSTER in img.get("src")] # Sort the image elements by the timestamp in the filename (in the link) (newest first) sorted_image_links = sorted(image_links, key=self.extract_timestamp, reverse=True) @@ -170,9 +185,9 @@ def query_data(self) -> str: pixel_byte_data = pil_image.tobytes() # Validate the image content starts with our validation header - if pixel_byte_data.startswith(b"WittyWisterias"): + if pixel_byte_data.startswith(self.FILE_SEARCH_TERM.encode()): # Remove the validation header and noise bytes from the first valid image - no_header_data = pixel_byte_data[len(b"WittyWisterias") + 8 :] + no_header_data = pixel_byte_data[len(self.FILE_SEARCH_TERM.encode()) + 8 :] # Remove any padding bytes (if any) to get the original data no_padding_data = no_header_data.rstrip(b"\x00") # Decode bytes into string and return it @@ -190,6 +205,7 @@ def upload_data(self, data: str) -> None: data (str): The data to upload, encoded in a string. Raises: + ValueError: If the resulting image exceeds the size limit of 20MB. InvalidResponseError: If the upload fails or the response is not as expected. """ # Convert the string data to bytes From 4139f09cc899bacc5d2cfab02aec995d4c555360 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sun, 10 Aug 2025 09:40:49 +0200 Subject: [PATCH 18/56] Fix Formatting/Linting --- pyproject.toml | 5 +++ src/__init__.py | 0 .../format.py => modules/message_format.py} | 43 +++++++++++-------- 3 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 src/__init__.py rename src/witty_wisterias/{message_format/format.py => modules/message_format.py} (64%) diff --git a/pyproject.toml b/pyproject.toml index 8586f74f..b3bc74ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,11 @@ ignore = [ "S105", # Commented-out code "ERA001", + # Functions with too many arguments + "PLR0913", + # Boolean Type Arguments + "FBT001", + "FBT002" ] [tool.ruff.lint.pydocstyle] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/witty_wisterias/message_format/format.py b/src/witty_wisterias/modules/message_format.py similarity index 64% rename from src/witty_wisterias/message_format/format.py rename to src/witty_wisterias/modules/message_format.py index b9b2eb93..f7f6c27e 100644 --- a/src/witty_wisterias/message_format/format.py +++ b/src/witty_wisterias/modules/message_format.py @@ -1,5 +1,17 @@ import json -from typing import Any, Dict, Optional +from typing import TypedDict + + +class MessageJson(TypedDict): + """ + Defines the structure of the JSON representation of a message. + This is used for serialization and deserialization of messages. + """ + + header: dict[str, str | None] + body: dict[str, str | dict[str, str]] + previous_messages: list["MessageFormat"] + stop_signal: bool class MessageFormat: @@ -11,14 +23,14 @@ class MessageFormat: def __init__( self, sender_id: str, - content: Any, + content: str, event_type: str, - receiver_id: Optional[str] = None, - public_key: Optional[str] = None, - extra_event_info: Optional[Dict] = None, - previous_messages: Optional[list] = None, - stop_signal: bool = False - ): + receiver_id: str | None = None, + public_key: str | None = None, + extra_event_info: dict[str, str] | None = None, + previous_messages: list["MessageFormat"] | None = None, + stop_signal: bool = False, + ) -> None: self.sender_id = sender_id self.receiver_id = receiver_id self.event_type = event_type @@ -28,21 +40,18 @@ def __init__( self.previous_messages = previous_messages or [] self.stop_signal = stop_signal - def to_dict(self) -> Dict: + def to_dict(self) -> MessageJson: """Convert the message into a Python dictionary.""" return { "header": { "sender_id": self.sender_id, "receiver_id": self.receiver_id, "event_type": self.event_type, - "public_key": self.public_key - }, - "body": { - "content": self.content, - "extra_event_info": self.extra_event_info + "public_key": self.public_key, }, + "body": {"content": self.content, "extra_event_info": self.extra_event_info}, "previous_messages": self.previous_messages, - "stop_signal": self.stop_signal + "stop_signal": self.stop_signal, } def to_json(self) -> str: @@ -61,5 +70,5 @@ def from_json(data: str) -> "MessageFormat": content=obj["body"]["content"], extra_event_info=obj["body"].get("extra_event_info", {}), previous_messages=obj.get("previous_messages", []), - stop_signal=obj.get("stop_signal", False) - ) \ No newline at end of file + stop_signal=obj.get("stop_signal", False), + ) From c620c745b012a53363907df11e28d436d7dcc060 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sun, 10 Aug 2025 10:38:37 +0200 Subject: [PATCH 19/56] Add EventType Typing System --- src/witty_wisterias/modules/message_format.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/witty_wisterias/modules/message_format.py b/src/witty_wisterias/modules/message_format.py index f7f6c27e..0a3db060 100644 --- a/src/witty_wisterias/modules/message_format.py +++ b/src/witty_wisterias/modules/message_format.py @@ -1,7 +1,21 @@ import json +from enum import Enum, auto from typing import TypedDict +# TODO: We should probably move types to a separate file for better organization (and exceptions.py too), doesnt belong +# in /modules/... +class EventType(Enum): + """Enumeration for different task types.""" + + PUBLIC_TEXT = auto() + PUBLIC_IMAGE = auto() + PRIVATE_TEXT = auto() + PRIVATE_IMAGE = auto() + SET_USERNAME = auto() + SET_PROFILEPICTURE = auto() + + class MessageJson(TypedDict): """ Defines the structure of the JSON representation of a message. @@ -24,7 +38,7 @@ def __init__( self, sender_id: str, content: str, - event_type: str, + event_type: EventType, receiver_id: str | None = None, public_key: str | None = None, extra_event_info: dict[str, str] | None = None, @@ -46,7 +60,7 @@ def to_dict(self) -> MessageJson: "header": { "sender_id": self.sender_id, "receiver_id": self.receiver_id, - "event_type": self.event_type, + "event_type": self.event_type.name, "public_key": self.public_key, }, "body": {"content": self.content, "extra_event_info": self.extra_event_info}, @@ -65,7 +79,7 @@ def from_json(data: str) -> "MessageFormat": return MessageFormat( sender_id=obj["header"]["sender_id"], receiver_id=obj["header"].get("receiver_id"), - event_type=obj["header"]["event_type"], + event_type=EventType[obj["header"]["event_type"]], public_key=obj["header"].get("public_key"), content=obj["body"]["content"], extra_event_info=obj["body"].get("extra_event_info", {}), From 30cc6fd668ae69550dcfe750f24e9e4fad7c0277 Mon Sep 17 00:00:00 2001 From: rif av Date: Sun, 10 Aug 2025 19:58:20 +0300 Subject: [PATCH 20/56] make the urls constants, and add NoReturn to the functions that raises an error. NOTE: check line 108. the function returns None but the docstring says it returns the url. --- src/witty_wisterias/modules/database.py | 27 ++++++++++++++++--------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/witty_wisterias/modules/database.py b/src/witty_wisterias/modules/database.py index 7bdf8e71..e3725959 100644 --- a/src/witty_wisterias/modules/database.py +++ b/src/witty_wisterias/modules/database.py @@ -1,8 +1,9 @@ import math import random import re -from datetime import UTC, datetime +from datetime import datetime, UTC from io import BytesIO +from typing import NoReturn # Using httpx instead of requests as it is more modern and has built-in typing support import httpx @@ -12,6 +13,12 @@ from .exceptions import InvalidResponseError +# constants for readability and it would be easier to change the url in case we change hoster +HOSTER_URL = "https://freeimghost.net/" +UPLOAD_URL = HOSTER_URL + "upload" +JSON_URL = HOSTER_URL + "json" +SEARCH_URL = lambda query: HOSTER_URL + f"search/images/?q={query}" # insert the query into the url + class Database: """ @@ -72,7 +79,7 @@ def base64_to_image(data: bytes) -> bytes: pil_image.save(buffer, format="PNG") return buffer.getvalue() - def get_configuration_data(self) -> str: + def get_configuration_data(self) -> str | NoReturn: """ Fetches the necessary configuration data for uploading images to the database. @@ -83,7 +90,7 @@ def get_configuration_data(self) -> str: InvalidResponseError: If the configuration data cannot be fetched or the auth token is not found. """ # Getting necessary configuration data for upload - config_response = self.session.get("https://freeimghost.net/upload") + config_response = self.session.get(UPLOAD_URL) # Check if the response is successful if config_response.status_code != 200: raise InvalidResponseError("Failed to fetch configuration data from the image hosting service.") @@ -96,9 +103,9 @@ def get_configuration_data(self) -> str: # Extracting auth token return match.group(1) - def upload_image(self, image_bytes: bytes) -> None: + def upload_image(self, image_bytes: bytes) -> NoReturn | None: """ - Uploads the image bytes to the database and returns the URL. + Uploads the image bytes to the database and returns the URL. <- what??? Args: image_bytes (bytes): The image bytes to upload. @@ -117,7 +124,7 @@ def upload_image(self, image_bytes: bytes) -> None: # Post Image to Image Hosting Service response = self.session.post( - url="https://freeimghost.net/json", + url=JSON_URL, files={ "source": (f"WittyWisterias_{utc_timestamp}.png", image_bytes, "image/png"), }, @@ -135,7 +142,7 @@ def upload_image(self, image_bytes: bytes) -> None: if response.status_code != 200: raise InvalidResponseError("Failed to upload image to the image hosting service.") - def query_data(self) -> str: + def query_data(self) -> str | NoReturn: """ Queries the latest data from the database. @@ -146,7 +153,7 @@ def query_data(self) -> str: InvalidResponseError: If the query fails or the response is not as expected. """ # Query all images with the search term "WittyWisterias" from the image hosting service - response = self.session.get("https://freeimghost.net/search/images/?q=WittyWisterias") + response = self.session.get(SEARCH_URL("WittyWisterias")) # Check if the response is successful if response.status_code != 200: raise InvalidResponseError("Failed to query latest image from the image hosting service.") @@ -154,7 +161,7 @@ def query_data(self) -> str: # Extracting the latest image URL from the response using beautifulsoup soup = BeautifulSoup(response.text, "html.parser") # Find all image elements which are hosted on the image hosting service - image_links = [img.get("src") for img in soup.find_all("img") if "https://freeimghost.net/" in img.get("src")] + image_links = [img.get("src") for img in soup.find_all("img") if HOSTER_URL in img.get("src")] # Sort the image elements by the timestamp in the filename (in the link) (newest first) sorted_image_links = sorted(image_links, key=self.extract_timestamp, reverse=True) @@ -182,7 +189,7 @@ def query_data(self) -> str: # If no valid image is found, raise an error raise InvalidResponseError("No valid image found in the response.") - def upload_data(self, data: str) -> None: + def upload_data(self, data: str) -> NoReturn | None: """ Uploads string encoded data as an image to the database hosted on the Image Hosting Service. From ba0e185a9fea35c3c95f06e23e17c4968be09e5e Mon Sep 17 00:00:00 2001 From: rif av Date: Sun, 10 Aug 2025 22:24:41 +0300 Subject: [PATCH 21/56] passed ruff checks and mypy checks, turns out NoReturn should only be alone, not with Union --- src/witty_wisterias/modules/database.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/witty_wisterias/modules/database.py b/src/witty_wisterias/modules/database.py index e3725959..8b395cd2 100644 --- a/src/witty_wisterias/modules/database.py +++ b/src/witty_wisterias/modules/database.py @@ -1,9 +1,8 @@ import math import random import re -from datetime import datetime, UTC +from datetime import UTC, datetime from io import BytesIO -from typing import NoReturn # Using httpx instead of requests as it is more modern and has built-in typing support import httpx @@ -17,7 +16,11 @@ HOSTER_URL = "https://freeimghost.net/" UPLOAD_URL = HOSTER_URL + "upload" JSON_URL = HOSTER_URL + "json" -SEARCH_URL = lambda query: HOSTER_URL + f"search/images/?q={query}" # insert the query into the url + + +def search_url(query: str) -> str: + """Insert the query into the url and return it""" + return HOSTER_URL + f"search/images/?q={query}" class Database: @@ -79,7 +82,7 @@ def base64_to_image(data: bytes) -> bytes: pil_image.save(buffer, format="PNG") return buffer.getvalue() - def get_configuration_data(self) -> str | NoReturn: + def get_configuration_data(self) -> str: """ Fetches the necessary configuration data for uploading images to the database. @@ -103,7 +106,7 @@ def get_configuration_data(self) -> str | NoReturn: # Extracting auth token return match.group(1) - def upload_image(self, image_bytes: bytes) -> NoReturn | None: + def upload_image(self, image_bytes: bytes) -> None: """ Uploads the image bytes to the database and returns the URL. <- what??? @@ -142,7 +145,7 @@ def upload_image(self, image_bytes: bytes) -> NoReturn | None: if response.status_code != 200: raise InvalidResponseError("Failed to upload image to the image hosting service.") - def query_data(self) -> str | NoReturn: + def query_data(self) -> str: """ Queries the latest data from the database. @@ -153,7 +156,7 @@ def query_data(self) -> str | NoReturn: InvalidResponseError: If the query fails or the response is not as expected. """ # Query all images with the search term "WittyWisterias" from the image hosting service - response = self.session.get(SEARCH_URL("WittyWisterias")) + response = self.session.get(search_url("WittyWisterias")) # Check if the response is successful if response.status_code != 200: raise InvalidResponseError("Failed to query latest image from the image hosting service.") @@ -164,7 +167,7 @@ def query_data(self) -> str | NoReturn: image_links = [img.get("src") for img in soup.find_all("img") if HOSTER_URL in img.get("src")] # Sort the image elements by the timestamp in the filename (in the link) (newest first) - sorted_image_links = sorted(image_links, key=self.extract_timestamp, reverse=True) + sorted_image_links: list[str] = sorted(image_links, key=self.extract_timestamp, reverse=True) # Find the first image link that contains our validation header and return its pixel byte data for image_link in sorted_image_links: @@ -189,7 +192,7 @@ def query_data(self) -> str | NoReturn: # If no valid image is found, raise an error raise InvalidResponseError("No valid image found in the response.") - def upload_data(self, data: str) -> NoReturn | None: + def upload_data(self, data: str) -> None: """ Uploads string encoded data as an image to the database hosted on the Image Hosting Service. From 7f9aaf267f770b86bdb1e1b1138d16db431c9666 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:03:04 +0200 Subject: [PATCH 22/56] Adjust to Main Branch --- src/witty_wisterias/modules/database.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/witty_wisterias/modules/database.py b/src/witty_wisterias/modules/database.py index f6bd7caa..72ae7649 100644 --- a/src/witty_wisterias/modules/database.py +++ b/src/witty_wisterias/modules/database.py @@ -12,10 +12,12 @@ from .exceptions import InvalidResponseError -# constants for readability and it would be easier to change the url in case we change hoster +# Image Hoster URL and API Endpoints HOSTER_URL = "https://freeimghost.net/" UPLOAD_URL = HOSTER_URL + "upload" JSON_URL = HOSTER_URL + "json" +# Search Term used to query for our images (and name our files) +FILE_SEARCH_TERM = "WittyWisterias" def search_url(query: str) -> str: @@ -31,14 +33,6 @@ class Database: We will later be able to query for the latest messages via https://freeimghost.net/search/images/?q={SearchTerm} """ - # Image Hoster URL and API Endpoints - IMAGE_HOSTER = "https://freeimghost.net" - CONFIG_URL = IMAGE_HOSTER + "/upload" - UPLOAD_URL = IMAGE_HOSTER + "/json" - QUERY_SEARCH_URL = IMAGE_HOSTER + "/search/images/?q=" - # Search Term used to query for our images (and name our files) - FILE_SEARCH_TERM = "WittyWisterias" - def __init__(self) -> None: self.session = httpx.Client() @@ -59,7 +53,8 @@ def extract_timestamp(url: str) -> float: return float(match.group(1)) return 0.0 - def base64_to_image(self, data: bytes) -> bytes: + @staticmethod + def base64_to_image(data: bytes) -> bytes: """ Converts arbitrary byte encoded data to image bytes. @@ -74,7 +69,7 @@ def base64_to_image(self, data: bytes) -> bytes: """ # Prepend Custom Message Header for later Image Validation # We also add a random noise header to avoid duplicates - header = self.FILE_SEARCH_TERM.encode() + random.randbytes(8) + header = FILE_SEARCH_TERM.encode() + random.randbytes(8) validation_data = header + data # Check how many total pixels we need @@ -144,7 +139,7 @@ def upload_image(self, image_bytes: bytes) -> None: response = self.session.post( url=JSON_URL, files={ - "source": (f"{self.FILE_SEARCH_TERM}_{utc_timestamp}.png", image_bytes, "image/png"), + "source": (f"{FILE_SEARCH_TERM}_{utc_timestamp}.png", image_bytes, "image/png"), }, data={ "type": "file", @@ -195,9 +190,9 @@ def query_data(self) -> str: pixel_byte_data = pil_image.tobytes() # Validate the image content starts with our validation header - if pixel_byte_data.startswith(self.FILE_SEARCH_TERM.encode()): + if pixel_byte_data.startswith(FILE_SEARCH_TERM.encode()): # Remove the validation header and noise bytes from the first valid image - no_header_data = pixel_byte_data[len(self.FILE_SEARCH_TERM.encode()) + 8 :] + no_header_data = pixel_byte_data[len(FILE_SEARCH_TERM.encode()) + 8 :] # Remove any padding bytes (if any) to get the original data no_padding_data = no_header_data.rstrip(b"\x00") # Decode bytes into string and return it From 623d64c754b07127866ac73e51cc7ec57e2b2c2e Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 11 Aug 2025 13:02:44 +0200 Subject: [PATCH 23/56] Create Cryptography Functionality - Implemented UserID Generation - Implemented Public Key Encryption - Implemented Digital Key Signing --- requirements.txt | 1 + src/witty_wisterias/modules/cryptography.py | 158 ++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/witty_wisterias/modules/cryptography.py diff --git a/requirements.txt b/requirements.txt index 1256d1d1..2c4316de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ types-requests beautifulsoup4~=4.13.4 httpx~=0.28.1 pillow~=11.3.0 +PyNaCl~=1.5.0 xxhash~=3.5.0 diff --git a/src/witty_wisterias/modules/cryptography.py b/src/witty_wisterias/modules/cryptography.py new file mode 100644 index 00000000..d9649802 --- /dev/null +++ b/src/witty_wisterias/modules/cryptography.py @@ -0,0 +1,158 @@ +import base64 +import random + +from nacl.public import Box, PrivateKey, PublicKey +from nacl.signing import SigningKey, VerifyKey + + +class Cryptography: + """ + A class to handle cryptographic operations of our chat App. + Handles Public-Key Encryption and Digital Signatures of messages. + """ + + @staticmethod + def generate_random_user_id() -> str: + """ + Generates a random UserID which will be 48bit (fits exactly 2 RGB pixels). + Note: The chance that two users having the same UserID is 1 in 281,474,976,710,656. + So for this CodeJam, we can safely assume that this will never happen and don't check for Duplicates. + + Returns: + str: A random 48-bit UserID encoded in base64. + """ + # Generate random 48-bit integer (0 to 2^48 - 1) + user_id_bits = random.getrandbits(48) + # Convert to bytes (6 bytes for 48 bits) + user_id_bytes = user_id_bits.to_bytes(6, byteorder="big") + # Encode to base64 + return base64.b64encode(user_id_bytes).decode("utf-8") + + @staticmethod + def generate_signing_key_pair() -> tuple[str, str]: + """ + Generates a new base64-encoded signing-verify key pair for signing messages. + + Returns: + str: The base64-encoded signing key. + str: The base64-encoded verify key. + """ + # Generate a new signing key + nacl_signing_key = SigningKey.generate() + nacl_verify_key = nacl_signing_key.verify_key + # Encode the keys in base64 + encoded_signing_key = base64.b64encode(nacl_signing_key.encode()).decode("utf-8") + encoded_verify_key = base64.b64encode(nacl_verify_key.encode()).decode("utf-8") + # Return the signing key and its verify key in base64 encoding + return encoded_signing_key, encoded_verify_key + + @staticmethod + def generate_encryption_key_pair() -> tuple[str, str]: + """ + Generates a new base64-encoded private-public key pair. + + Returns: + str: The base64-encoded private key. + str: The base64-encoded public key. + """ + # Generate a new private key + nacl_private_key = PrivateKey.generate() + nacl_public_key = nacl_private_key.public_key + # Encode the keys in base64 + encoded_private_key = base64.b64encode(nacl_private_key.encode()).decode("utf-8") + encoded_public_key = base64.b64encode(nacl_public_key.encode()).decode("utf-8") + # Return the private key and its public key in base64 encoding + return encoded_private_key, encoded_public_key + + @staticmethod + def sign_message(message: str, signing_key: str) -> str: + """ + Signs a message using the provided signing key. + + Args: + message (str): The message to sign. + signing_key (str): The base64-encoded signing key. + + Returns: + str: The signed, base64-encoded message. + """ + # Decode the signing key from base64 + signing_key_bytes = base64.b64decode(signing_key) + # Create a SigningKey object + nacl_signing_key = SigningKey(signing_key_bytes) + # Sign the message + signed_message = nacl_signing_key.sign(message.encode("utf-8")) + return base64.b64encode(signed_message).decode("utf-8") + + @staticmethod + def verify_message(signed_message: str, verify_key: str) -> str: + """ + Verifies a signed message using the provided verify key. + + Args: + signed_message (str): The signed, base64-encoded message. + verify_key (str): The base64-encoded verify key. + + Returns: + str: The original message if verification is successful. + + Raises: + ValueError: If the verification fails. + """ + # Decode the signed message and verify key from base64 + signed_message_bytes = base64.b64decode(signed_message) + verify_key_bytes = base64.b64decode(verify_key) + # Create a VerifyKey object + nacl_verify_key = VerifyKey(verify_key_bytes) + # Verify the signed message + try: + verified_message: bytes = nacl_verify_key.verify(signed_message_bytes) + return verified_message.decode("utf-8") + except Exception as e: + raise ValueError("Verification failed") from e + + @staticmethod + def encrypt_message(message: str, sender_private_key: str, recipient_public_key: str) -> str: + """ + Encrypts a message using the recipient's public key and the sender's private key. + + Args: + message (str): The message to encrypt. + sender_private_key (str): The sender's private key in base64 encoding. + recipient_public_key (str): The recipient's public key in base64 encoding. + + Returns: + str: The encrypted, base64-encoded message. + """ + # Decode the keys from base64 + sender_private_key_bytes = base64.b64decode(sender_private_key) + recipient_public_key_bytes = base64.b64decode(recipient_public_key) + # Create the Box for encryption + nacl_box = Box(PrivateKey(sender_private_key_bytes), PublicKey(recipient_public_key_bytes)) + # Encrypt the message + encrypted_message = nacl_box.encrypt(message.encode("utf-8")) + return base64.b64encode(encrypted_message).decode("utf-8") + + @staticmethod + def decrypt_message(encrypted_message: str, recipient_private_key: str, sender_public_key: str) -> str: + """ + Decrypts a message using the recipient's private key and the sender's public key. + + Args: + encrypted_message (str): The encrypted, base64-encoded message. + recipient_private_key (str): The recipient's private key in base64 encoding. + sender_public_key (str): The sender's public key in base64 encoding. + + Returns: + str: The decrypted message. + """ + # Decode the keys from base64 + recipient_private_key_bytes = base64.b64decode(recipient_private_key) + sender_public_key_bytes = base64.b64decode(sender_public_key) + # Create the Box for decryption + nacl_box = Box(PrivateKey(recipient_private_key_bytes), PublicKey(sender_public_key_bytes)) + # Decode the encrypted message from base64 + encrypted_message_bytes = base64.b64decode(encrypted_message) + # Decrypt the message + decrypted_message: bytes = nacl_box.decrypt(encrypted_message_bytes) + return decrypted_message.decode("utf-8") From 3ac326c1dd5ebec480a494ba05cfbe32ecc766e2 Mon Sep 17 00:00:00 2001 From: Ben Gilbert Date: Mon, 11 Aug 2025 13:12:15 +0100 Subject: [PATCH 24/56] implemented skeleton for frontend chat --- src/frontend/.gitignore | 6 +++++ src/frontend/__init__.py | 0 src/frontend/assets/favicon.ico | Bin 0 -> 4286 bytes src/frontend/frontend/__init__.py | 0 src/frontend/frontend/frontend.py | 43 ++++++++++++++++++++++++++++++ src/frontend/requirements.txt | 2 ++ src/frontend/rxconfig.py | 9 +++++++ 7 files changed, 60 insertions(+) create mode 100644 src/frontend/.gitignore create mode 100644 src/frontend/__init__.py create mode 100644 src/frontend/assets/favicon.ico create mode 100644 src/frontend/frontend/__init__.py create mode 100644 src/frontend/frontend/frontend.py create mode 100644 src/frontend/requirements.txt create mode 100644 src/frontend/rxconfig.py diff --git a/src/frontend/.gitignore b/src/frontend/.gitignore new file mode 100644 index 00000000..162c09ed --- /dev/null +++ b/src/frontend/.gitignore @@ -0,0 +1,6 @@ +.states +assets/external/ +*.py[cod] +.web +*.db +__pycache__/ diff --git a/src/frontend/__init__.py b/src/frontend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/frontend/assets/favicon.ico b/src/frontend/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..166ae995eaa63fc96771410a758282dc30e925cf GIT binary patch literal 4286 zcmeHL>rYc>81ELdEe;}zmYd}cUgmJRfwjUwD1`#s5KZP>mMqza#Viv|_7|8f+0+bX zHuqusuw-7Ca`DTu#4U4^o2bjO#K>4%N?Wdi*wZ3Vx%~Ef4}D1`U_EMRg3u z#2#M|V>}}q-@IaO@{9R}d*u7f&~5HfxSkmHVcazU#i30H zAGxQ5Spe!j9`KuGqR@aExK`-}sH1jvqoIp3C7Vm)9Tu=UPE;j^esN~a6^a$ZILngo;^ zGLXl(ZFyY&U!li`6}y-hUQ99v?s`U4O!kgog74FPw-9g+V)qs!jFGEQyvBf><U|E2vRmx|+(VI~S=lT?@~C5pvZOd`x{Q_+3tG6H=gtdWcf z)+7-Zp=UqH^J4sk^>_G-Ufn-2Hz z2mN12|C{5}U`^eCQuFz=F%wp@}SzA1MHEaM^CtJs<{}Tzu$bx2orTKiedgmtVGM{ zdd#vX`&cuiec|My_KW;y{Ryz2kFu9}=~us6hvx1ZqQCk(d+>HP>ks>mmHCjjDh{pe zKQkKpk0SeDX#XMqf$}QV{z=xrN!mQczJAvud@;zFqaU1ocq==Py)qsa=8UKrt!J7r z{RsTo^rgtZo%$rak)DN*D)!(Y^$@yL6Nd=#eu&?unzhH8yq>v{gkt8xcG3S%H)-y_ zqQ1|v|JT$0R~Y}omg2Y+nDvR+K|kzR5i^fmKF>j~N;A35Vr`JWh4yRqKl#P|qlx?` z@|CmBiP}ysYO%m2{eBG6&ix5 zr#u((F2{vb=W4jNmTQh3M^F2o80T49?w>*rv0mt)-o1y!{hRk`E#UVPdna6jnz`rw dKpn)r^--YJZpr;bYU`N~>#v3X5BRU&{{=gv-{1fM literal 0 HcmV?d00001 diff --git a/src/frontend/frontend/__init__.py b/src/frontend/frontend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/frontend/frontend/frontend.py b/src/frontend/frontend/frontend.py new file mode 100644 index 00000000..2c343b74 --- /dev/null +++ b/src/frontend/frontend/frontend.py @@ -0,0 +1,43 @@ +import reflex as rx + + +class State(rx.State): + """The app state.""" + + +def index() -> rx.Component: + """Welcome Page (Index)""" + return rx.container( + rx.vstack( + rx.aspect_ratio( + rx.box( + border_radius="6px", + class_name="border border-2 border-gray-500", + width="100%", + height="100%", + ), + ratio=1, + ), + rx.grid( + rx.button( + rx.center(rx.text("Send Text")), + padding="24px", + radius="large", + ), + rx.button( + rx.center(rx.text("Send Image")), + padding="24px", + radius="large", + ), + class_name="w-full", + columns="2", + spacing="4", + ), + class_name="w-full", + ), + size="2", + ) + + +app = rx.App() +app.add_page(index) diff --git a/src/frontend/requirements.txt b/src/frontend/requirements.txt new file mode 100644 index 00000000..2e4c1654 --- /dev/null +++ b/src/frontend/requirements.txt @@ -0,0 +1,2 @@ + +reflex==0.8.5 diff --git a/src/frontend/rxconfig.py b/src/frontend/rxconfig.py new file mode 100644 index 00000000..6f8ebb04 --- /dev/null +++ b/src/frontend/rxconfig.py @@ -0,0 +1,9 @@ +import reflex as rx + +config = rx.Config( + app_name="frontend", + plugins=[ + rx.plugins.SitemapPlugin(), + rx.plugins.TailwindV4Plugin(), + ], +) From ad86f1b1cb1d90c98dafad35b78431f5a1d0a5a4 Mon Sep 17 00:00:00 2001 From: Ben Gilbert Date: Mon, 11 Aug 2025 13:43:59 +0100 Subject: [PATCH 25/56] add send image dialog --- src/frontend/__init__.py | 0 src/frontend/frontend/frontend.py | 43 +++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) delete mode 100644 src/frontend/__init__.py diff --git a/src/frontend/__init__.py b/src/frontend/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/frontend/frontend/frontend.py b/src/frontend/frontend/frontend.py index 2c343b74..1baba0d4 100644 --- a/src/frontend/frontend/frontend.py +++ b/src/frontend/frontend/frontend.py @@ -5,6 +5,43 @@ class State(rx.State): """The app state.""" +def send_image_dialog() -> rx.Component: + """The dialog (and button) for sending an image""" + return rx.dialog.root( + rx.dialog.trigger( + rx.button( + rx.center(rx.text("Send Image")), + padding="24px", + radius="large", + ), + ), + rx.dialog.content( + rx.dialog.title("Send Image"), + rx.dialog.description( + "Send an image by describing it in the box below.", + size="2", + margin_bottom="16px", + ), + rx.text_area(placeholder="Describe it here..."), + rx.flex( + rx.dialog.close( + rx.button( + "Cancel", + color_scheme="gray", + variant="soft", + ), + ), + rx.dialog.close( + rx.button("Send"), + ), + spacing="3", + margin_top="16px", + justify="end", + ), + ), + ) + + def index() -> rx.Component: """Welcome Page (Index)""" return rx.container( @@ -24,11 +61,7 @@ def index() -> rx.Component: padding="24px", radius="large", ), - rx.button( - rx.center(rx.text("Send Image")), - padding="24px", - radius="large", - ), + send_image_dialog(), class_name="w-full", columns="2", spacing="4", From dd8a9ef6bab09b703253e80361a8d91307af0871 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 11 Aug 2025 19:11:30 +0200 Subject: [PATCH 26/56] Frontend Overhaul Reworked a bit of everything, introduced Sidebar --- requirements.txt | 1 + src/frontend/__init__.py | 0 src/frontend/frontend/components/__init__.py | 0 src/frontend/frontend/components/chatapp.py | 22 ++++++ .../frontend/components/image_button.py | 39 ++++++++++ src/frontend/frontend/components/sidebar.py | 53 ++++++++++++++ .../frontend/components/text_button.py | 40 ++++++++++ src/frontend/frontend/frontend.py | 73 ++----------------- src/frontend/frontend/states/__init__.py | 0 9 files changed, 162 insertions(+), 66 deletions(-) create mode 100644 src/frontend/__init__.py create mode 100644 src/frontend/frontend/components/__init__.py create mode 100644 src/frontend/frontend/components/chatapp.py create mode 100644 src/frontend/frontend/components/image_button.py create mode 100644 src/frontend/frontend/components/sidebar.py create mode 100644 src/frontend/frontend/components/text_button.py create mode 100644 src/frontend/frontend/states/__init__.py diff --git a/requirements.txt b/requirements.txt index 1256d1d1..67d7f6f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ types-requests beautifulsoup4~=4.13.4 httpx~=0.28.1 pillow~=11.3.0 +reflex~=0.8.5 xxhash~=3.5.0 diff --git a/src/frontend/__init__.py b/src/frontend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/frontend/frontend/components/__init__.py b/src/frontend/frontend/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/frontend/frontend/components/chatapp.py b/src/frontend/frontend/components/chatapp.py new file mode 100644 index 00000000..0dba5016 --- /dev/null +++ b/src/frontend/frontend/components/chatapp.py @@ -0,0 +1,22 @@ +import reflex as rx + +from frontend.components.image_button import send_image_component +from frontend.components.text_button import send_text_component + + +def chat_app() -> rx.Component: + """Main chat application component.""" + return rx.vstack( + rx.box( + "This is our chat app! (TEMP)", + class_name="h-full w-full mt-5 bg-gray-50 p-5 rounded-xl shadow-sm", + ), + rx.divider(), + rx.hstack( + send_text_component(), + send_image_component(), + class_name="mt-auto mb-3 w-full", + ), + spacing="4", + class_name="h-screen w-full mx-5", + ) diff --git a/src/frontend/frontend/components/image_button.py b/src/frontend/frontend/components/image_button.py new file mode 100644 index 00000000..c8f15af9 --- /dev/null +++ b/src/frontend/frontend/components/image_button.py @@ -0,0 +1,39 @@ +import reflex as rx + + +def send_image_component() -> rx.Component: + """The dialog (and button) for sending an image""" + return rx.dialog.root( + rx.dialog.trigger( + rx.button( + rx.center(rx.text("Send Image")), + padding="24px", + radius="large", + flex=1, + ), + ), + rx.dialog.content( + rx.dialog.title("Send Image"), + rx.dialog.description( + "Send an image by describing it in the box below.", + size="2", + margin_bottom="16px", + ), + rx.text_area(placeholder="Describe it here..."), + rx.flex( + rx.dialog.close( + rx.button( + "Cancel", + color_scheme="gray", + variant="soft", + ), + ), + rx.dialog.close( + rx.button("Send"), + ), + spacing="3", + margin_top="16px", + justify="end", + ), + ), + ) diff --git a/src/frontend/frontend/components/sidebar.py b/src/frontend/frontend/components/sidebar.py new file mode 100644 index 00000000..2bfea196 --- /dev/null +++ b/src/frontend/frontend/components/sidebar.py @@ -0,0 +1,53 @@ +import reflex as rx + + +def chat_sidebar() -> rx.Component: + """Sidebar component for the chat application, which allows users to select different chats.""" + return rx.el.div( + rx.vstack( + rx.hstack( + rx.heading("Witty Wisterias", size="6"), + rx.heading("v1.0.0", size="3", class_name="text-gray-500"), + spacing="2", + align="baseline", + justify="between", # Überschriften an die Seiten verteilen + class_name="w-full", + ), + rx.divider(), + rx.heading("Public Chat", size="2", class_name="text-gray-500"), + rx.button( + "Public Chat", + color_scheme="gray", + variant="surface", + size="3", + class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", + ), + rx.divider(), + rx.heading("Private Chats", size="2", class_name="text-gray-500"), + rx.button( + "Private Chat 1", + color_scheme="gray", + variant="surface", + size="3", + class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", + ), + rx.button( + "Private Chat 2", + color_scheme="gray", + variant="surface", + size="3", + class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", + ), + rx.hstack( + rx.avatar(fallback="RX", radius="large", size="3"), + rx.vstack( + rx.text("User Name", size="3"), + rx.text("UserID", size="1", class_name="text-gray-500"), + spacing="0", + ), + class_name="mt-auto mb-4", + ), + class_name="h-screen bg-gray-50", + ), + class_name="flex flex-col w-[320px] h-screen px-5 pt-3 border-r border-gray-200", + ) diff --git a/src/frontend/frontend/components/text_button.py b/src/frontend/frontend/components/text_button.py new file mode 100644 index 00000000..7b40e1f2 --- /dev/null +++ b/src/frontend/frontend/components/text_button.py @@ -0,0 +1,40 @@ +import reflex as rx + + +def send_text_component() -> rx.Component: + """The dialog (and button) for sending an texts""" + # TODO: This should be replaced with the Webcam handler, text will do for now + return rx.dialog.root( + rx.dialog.trigger( + rx.button( + rx.center(rx.text("Send Text")), + padding="24px", + radius="large", + flex=1, + ), + ), + rx.dialog.content( + rx.dialog.title("Send Text (TEMP)"), + rx.dialog.description( + "Send a text message to the chat. This is a temp feature until the webcam handler is implemented.", + size="2", + margin_bottom="16px", + ), + rx.text_area(placeholder="Write your text here..."), + rx.flex( + rx.dialog.close( + rx.button( + "Cancel", + color_scheme="gray", + variant="soft", + ), + ), + rx.dialog.close( + rx.button("Send"), + ), + spacing="3", + margin_top="16px", + justify="end", + ), + ), + ) diff --git a/src/frontend/frontend/frontend.py b/src/frontend/frontend/frontend.py index 1baba0d4..37b5d015 100644 --- a/src/frontend/frontend/frontend.py +++ b/src/frontend/frontend/frontend.py @@ -1,76 +1,17 @@ import reflex as rx - -class State(rx.State): - """The app state.""" - - -def send_image_dialog() -> rx.Component: - """The dialog (and button) for sending an image""" - return rx.dialog.root( - rx.dialog.trigger( - rx.button( - rx.center(rx.text("Send Image")), - padding="24px", - radius="large", - ), - ), - rx.dialog.content( - rx.dialog.title("Send Image"), - rx.dialog.description( - "Send an image by describing it in the box below.", - size="2", - margin_bottom="16px", - ), - rx.text_area(placeholder="Describe it here..."), - rx.flex( - rx.dialog.close( - rx.button( - "Cancel", - color_scheme="gray", - variant="soft", - ), - ), - rx.dialog.close( - rx.button("Send"), - ), - spacing="3", - margin_top="16px", - justify="end", - ), - ), - ) +from frontend.components.chatapp import chat_app +from frontend.components.sidebar import chat_sidebar def index() -> rx.Component: - """Welcome Page (Index)""" - return rx.container( - rx.vstack( - rx.aspect_ratio( - rx.box( - border_radius="6px", - class_name="border border-2 border-gray-500", - width="100%", - height="100%", - ), - ratio=1, - ), - rx.grid( - rx.button( - rx.center(rx.text("Send Text")), - padding="24px", - radius="large", - ), - send_image_dialog(), - class_name="w-full", - columns="2", - spacing="4", - ), - class_name="w-full", - ), + """The main page of the chat application, which includes the sidebar and chat app components.""" + return rx.hstack( + chat_sidebar(), + chat_app(), size="2", ) -app = rx.App() +app = rx.App(theme=rx.theme(appearance="light", has_background=True, radius="large", accent_color="teal")) app.add_page(index) diff --git a/src/frontend/frontend/states/__init__.py b/src/frontend/frontend/states/__init__.py new file mode 100644 index 00000000..e69de29b From da54a879628b584ac958f33667000328147c0109 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 11 Aug 2025 19:45:07 +0200 Subject: [PATCH 27/56] Apply Some Styling --- src/frontend/__init__.py | 0 src/frontend/frontend/components/sidebar.py | 16 ++++++++-------- src/frontend/frontend/frontend.py | 11 ++++++++++- 3 files changed, 18 insertions(+), 9 deletions(-) delete mode 100644 src/frontend/__init__.py diff --git a/src/frontend/__init__.py b/src/frontend/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/frontend/frontend/components/sidebar.py b/src/frontend/frontend/components/sidebar.py index 2bfea196..237c965b 100644 --- a/src/frontend/frontend/components/sidebar.py +++ b/src/frontend/frontend/components/sidebar.py @@ -10,14 +10,14 @@ def chat_sidebar() -> rx.Component: rx.heading("v1.0.0", size="3", class_name="text-gray-500"), spacing="2", align="baseline", - justify="between", # Überschriften an die Seiten verteilen - class_name="w-full", + justify="between", + class_name="w-full mb-0", ), rx.divider(), rx.heading("Public Chat", size="2", class_name="text-gray-500"), rx.button( "Public Chat", - color_scheme="gray", + color_scheme="teal", variant="surface", size="3", class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", @@ -26,28 +26,28 @@ def chat_sidebar() -> rx.Component: rx.heading("Private Chats", size="2", class_name="text-gray-500"), rx.button( "Private Chat 1", - color_scheme="gray", + color_scheme="teal", variant="surface", size="3", class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", ), rx.button( "Private Chat 2", - color_scheme="gray", + color_scheme="teal", variant="surface", size="3", class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", ), rx.hstack( - rx.avatar(fallback="RX", radius="large", size="3"), + rx.avatar(fallback="ID", radius="large", size="3"), rx.vstack( rx.text("User Name", size="3"), rx.text("UserID", size="1", class_name="text-gray-500"), spacing="0", ), - class_name="mt-auto mb-4", + class_name="mt-auto mb-5", ), class_name="h-screen bg-gray-50", ), - class_name="flex flex-col w-[320px] h-screen px-5 pt-3 border-r border-gray-200", + class_name="flex flex-col w-[340px] h-screen px-5 pt-3 mt-2 border-r border-gray-200", ) diff --git a/src/frontend/frontend/frontend.py b/src/frontend/frontend/frontend.py index 37b5d015..bd876019 100644 --- a/src/frontend/frontend/frontend.py +++ b/src/frontend/frontend/frontend.py @@ -10,8 +10,17 @@ def index() -> rx.Component: chat_sidebar(), chat_app(), size="2", + class_name="overflow-hidden h-screen w-full", ) -app = rx.App(theme=rx.theme(appearance="light", has_background=True, radius="large", accent_color="teal")) +app = rx.App( + theme=rx.theme(appearance="light", has_background=True, radius="large", accent_color="teal"), + stylesheets=[ + "https://fonts.googleapis.com/css2?family=Bitcount+Prop+Single:slnt,wght@-8,600&display=swapp", + ], + style={ + "font_family": "Bitcount Prop Single", + }, +) app.add_page(index) From 9312329fb73b8f47a87d71707308d940c07309f5 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 11 Aug 2025 23:17:04 +0200 Subject: [PATCH 28/56] Add Message Bubbles For now you can just enter text and an image url. --- .pre-commit-config.yaml | 1 + pyproject.toml | 3 + .../frontend/components/chat_bubble.py | 55 +++++++++++++++++++ src/frontend/frontend/components/chatapp.py | 17 +++++- .../frontend/components/image_button.py | 42 +++++++++----- .../frontend/components/text_button.py | 42 +++++++++----- src/frontend/frontend/frontend.py | 2 + 7 files changed, 131 insertions(+), 31 deletions(-) create mode 100644 src/frontend/frontend/components/chat_bubble.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 891efd42..cafd341d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,3 +35,4 @@ repos: rev: v1.17.1 hooks: - id: mypy + additional_dependencies: ['types-requests'] diff --git a/pyproject.toml b/pyproject.toml index 8586f74f..383a8742 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,9 @@ ignore = [ "S105", # Commented-out code "ERA001", + # Boolean Type Arguments + "FBT001", + "FBT002" ] [tool.ruff.lint.pydocstyle] diff --git a/src/frontend/frontend/components/chat_bubble.py b/src/frontend/frontend/components/chat_bubble.py new file mode 100644 index 00000000..92c9ae6c --- /dev/null +++ b/src/frontend/frontend/components/chat_bubble.py @@ -0,0 +1,55 @@ +import reflex as rx +from PIL import Image + + +def chat_bubble_component( + message: str | Image.Image, + user_name: str, + user_profile_image: str | None = None, + own_message: bool = False, + is_image_message: bool = False, +) -> rx.Component: + """Creates a chat bubble component for displaying messages in the chat application. + + Args: + message (str): The content of the message, either text or base64-encoded image. + user_name (str): The name of the user who sent the message. + user_profile_image (str): The URL of the user's profile image. + own_message (bool): Whether the message is sent by the current user. + is_image_message (bool): Whether the message is an image. If True, `message` should be a Pillow Image. + + Returns: + rx.Component: A component representing the chat bubble. + """ + avatar = rx.avatar( + src=user_profile_image, + fallback=user_name[:2], + radius="large", + size="3", + ) + message_content = rx.vstack( + rx.text(user_name, class_name="font-semibold"), + rx.cond( + is_image_message, + rx.image(src=message, alt="Image message", max_width="500px", max_height="500px"), + rx.text(message, class_name="text-gray-800"), + ), + class_name="rounded-lg", + spacing="0", + ) + + return rx.hstack( + rx.cond( + own_message, + [ + message_content, + avatar, + ], + [ + avatar, + message_content, + ], + ), + class_name="items-start space-x-2 bg-gray-100 p-4 rounded-lg shadow-sm", + style={"alignSelf": rx.cond(own_message, "flex-end", "flex-start")}, + ) diff --git a/src/frontend/frontend/components/chatapp.py b/src/frontend/frontend/components/chatapp.py index 0dba5016..bb2c72b0 100644 --- a/src/frontend/frontend/components/chatapp.py +++ b/src/frontend/frontend/components/chatapp.py @@ -1,15 +1,26 @@ import reflex as rx +from frontend.components.chat_bubble import chat_bubble_component from frontend.components.image_button import send_image_component from frontend.components.text_button import send_text_component +from frontend.states.chat_state import ChatState def chat_app() -> rx.Component: """Main chat application component.""" return rx.vstack( - rx.box( - "This is our chat app! (TEMP)", - class_name="h-full w-full mt-5 bg-gray-50 p-5 rounded-xl shadow-sm", + rx.auto_scroll( + rx.foreach( + ChatState.messages, + lambda message: chat_bubble_component( + message["message"], + message["user_name"], + message["user_profile_image"], + message["own_message"], + message["is_image_message"], + ), + ), + class_name="flex flex-col gap-4 pb-6 pt-6 h-full w-full mt-5 bg-gray-50 p-5 rounded-xl shadow-sm", ), rx.divider(), rx.hstack( diff --git a/src/frontend/frontend/components/image_button.py b/src/frontend/frontend/components/image_button.py index c8f15af9..083d19c3 100644 --- a/src/frontend/frontend/components/image_button.py +++ b/src/frontend/frontend/components/image_button.py @@ -1,5 +1,7 @@ import reflex as rx +from frontend.states.chat_state import ChatState + def send_image_component() -> rx.Component: """The dialog (and button) for sending an image""" @@ -15,25 +17,37 @@ def send_image_component() -> rx.Component: rx.dialog.content( rx.dialog.title("Send Image"), rx.dialog.description( - "Send an image by describing it in the box below.", + "Send an image by describing it in the box below. TEMP: You can post an image URL.", size="2", margin_bottom="16px", ), - rx.text_area(placeholder="Describe it here..."), - rx.flex( - rx.dialog.close( - rx.button( - "Cancel", - color_scheme="gray", - variant="soft", + rx.form( + rx.flex( + rx.text_area( + placeholder="Describe it here...", + size="3", + rows="5", + name="message", + required=True, + variant="surface", + class_name="w-full", ), + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ), + ), + rx.dialog.close( + rx.button("Send", type="submit"), + ), + spacing="3", + margin_top="16px", + justify="end", ), - rx.dialog.close( - rx.button("Send"), - ), - spacing="3", - margin_top="16px", - justify="end", + on_submit=ChatState.send_image, + reset_on_submit=False, ), ), ) diff --git a/src/frontend/frontend/components/text_button.py b/src/frontend/frontend/components/text_button.py index 7b40e1f2..b04689c5 100644 --- a/src/frontend/frontend/components/text_button.py +++ b/src/frontend/frontend/components/text_button.py @@ -1,8 +1,10 @@ import reflex as rx +from frontend.states.chat_state import ChatState + def send_text_component() -> rx.Component: - """The dialog (and button) for sending an texts""" + """The dialog (and button) for sending texts""" # TODO: This should be replaced with the Webcam handler, text will do for now return rx.dialog.root( rx.dialog.trigger( @@ -20,21 +22,33 @@ def send_text_component() -> rx.Component: size="2", margin_bottom="16px", ), - rx.text_area(placeholder="Write your text here..."), - rx.flex( - rx.dialog.close( - rx.button( - "Cancel", - color_scheme="gray", - variant="soft", + rx.form( + rx.flex( + rx.text_area( + placeholder="Write your text here...", + size="3", + rows="5", + name="message", + required=True, + variant="surface", + class_name="w-full", ), + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ), + ), + rx.dialog.close( + rx.button("Send", type="submit"), + ), + spacing="3", + margin_top="16px", + justify="end", ), - rx.dialog.close( - rx.button("Send"), - ), - spacing="3", - margin_top="16px", - justify="end", + on_submit=ChatState.send_text, + reset_on_submit=False, ), ), ) diff --git a/src/frontend/frontend/frontend.py b/src/frontend/frontend/frontend.py index bd876019..80f37121 100644 --- a/src/frontend/frontend/frontend.py +++ b/src/frontend/frontend/frontend.py @@ -2,8 +2,10 @@ from frontend.components.chatapp import chat_app from frontend.components.sidebar import chat_sidebar +from frontend.states.chat_state import ChatState +@rx.page(on_load=ChatState.check_messages) def index() -> rx.Component: """The main page of the chat application, which includes the sidebar and chat app components.""" return rx.hstack( From 433905cd457a6ea78c335db81339f5759afe837d Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Tue, 12 Aug 2025 08:43:45 +0200 Subject: [PATCH 29/56] Forgot to push ChatState --- src/frontend/frontend/states/chat_state.py | 87 ++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/frontend/frontend/states/chat_state.py diff --git a/src/frontend/frontend/states/chat_state.py b/src/frontend/frontend/states/chat_state.py new file mode 100644 index 00000000..05c5d32f --- /dev/null +++ b/src/frontend/frontend/states/chat_state.py @@ -0,0 +1,87 @@ +import asyncio +import io +import random +from typing import TypedDict + +import reflex as rx +import requests +from PIL import Image + + +class Message(TypedDict): + """A message in the chat application.""" + + message: str | Image.Image + user_id: str + user_name: str + user_profile_image: str | None + own_message: bool + is_image_message: bool + + +class ChatState(rx.State): + """The Chat app state, used to handle Messages.""" + + messages: list[Message] = rx.field(default_factory=list) + + @rx.event + def send_text(self, form_data: dict[str, str]) -> None: + """ + Reflex Event when a text message is sent. + + Args: + form_data (dict[str, str]): The form data containing the message in the `message` field. + """ + message = form_data.get("message", "").strip() + if message: + self.messages.append( + Message( + message=message, + user_id="ME", + user_name="User Name", + user_profile_image=None, + own_message=True, + is_image_message=False, + ) + ) + + @rx.event + def send_image(self, form_data: dict[str, str]) -> None: + """ + Reflex Event when an image message is sent. + + Args: + form_data (dict[str, str]): The form data containing the image URL in the `message` field. + """ + message = form_data.get("message", "").strip() + if message: + response = requests.get(message, timeout=10) + img = Image.open(io.BytesIO(response.content)) + self.messages.append( + Message( + message=img, + user_id="ME", + user_name="User Name", + user_profile_image=None, + own_message=True, + is_image_message=True, + ) + ) + + @rx.event(background=True) + async def check_messages(self) -> None: + """Reflex Background Check for new messages.""" + while True: + # Simulate checking for new messages + await asyncio.sleep(random.randint(5, 10)) + async with self: + self.messages.append( + Message( + message="New message from background task", + user_id="BOT", + user_name="Bot", + user_profile_image=None, + own_message=False, + is_image_message=False, + ) + ) From e6cf4ba7190adc1bd9a6060d1f9142a4c70f6028 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:23:04 +0200 Subject: [PATCH 30/56] Fix Linting --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 383a8742..1785e1b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,9 @@ ignore = [ "ERA001", # Boolean Type Arguments "FBT001", - "FBT002" + "FBT002", + # Implicit Namespaces, conflicts with Reflex + "INP001", ] [tool.ruff.lint.pydocstyle] From 82aba65c0f83832edee6d6c5848e58d2aaa306b5 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Tue, 12 Aug 2025 16:42:09 +0200 Subject: [PATCH 31/56] Delete Stop Signal --- src/witty_wisterias/modules/message_format.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/witty_wisterias/modules/message_format.py b/src/witty_wisterias/modules/message_format.py index 0a3db060..43d653fa 100644 --- a/src/witty_wisterias/modules/message_format.py +++ b/src/witty_wisterias/modules/message_format.py @@ -25,7 +25,6 @@ class MessageJson(TypedDict): header: dict[str, str | None] body: dict[str, str | dict[str, str]] previous_messages: list["MessageFormat"] - stop_signal: bool class MessageFormat: @@ -43,7 +42,6 @@ def __init__( public_key: str | None = None, extra_event_info: dict[str, str] | None = None, previous_messages: list["MessageFormat"] | None = None, - stop_signal: bool = False, ) -> None: self.sender_id = sender_id self.receiver_id = receiver_id @@ -52,7 +50,6 @@ def __init__( self.content = content self.extra_event_info = extra_event_info or {} self.previous_messages = previous_messages or [] - self.stop_signal = stop_signal def to_dict(self) -> MessageJson: """Convert the message into a Python dictionary.""" @@ -65,7 +62,6 @@ def to_dict(self) -> MessageJson: }, "body": {"content": self.content, "extra_event_info": self.extra_event_info}, "previous_messages": self.previous_messages, - "stop_signal": self.stop_signal, } def to_json(self) -> str: @@ -84,5 +80,4 @@ def from_json(data: str) -> "MessageFormat": content=obj["body"]["content"], extra_event_info=obj["body"].get("extra_event_info", {}), previous_messages=obj.get("previous_messages", []), - stop_signal=obj.get("stop_signal", False), ) From 9153648a6cec9f37412c82e3507677fe8c86838d Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 13 Aug 2025 10:49:05 +0200 Subject: [PATCH 32/56] Rename Cryptography to Cryptographer to avoid Naming Issues Co-Authored-By: Erri4 <118684880+erri4@users.noreply.github.com> --- .../modules/{cryptography.py => cryptographer.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/witty_wisterias/modules/{cryptography.py => cryptographer.py} (99%) diff --git a/src/witty_wisterias/modules/cryptography.py b/src/witty_wisterias/modules/cryptographer.py similarity index 99% rename from src/witty_wisterias/modules/cryptography.py rename to src/witty_wisterias/modules/cryptographer.py index d9649802..85cc0b47 100644 --- a/src/witty_wisterias/modules/cryptography.py +++ b/src/witty_wisterias/modules/cryptographer.py @@ -5,7 +5,7 @@ from nacl.signing import SigningKey, VerifyKey -class Cryptography: +class Cryptographer: """ A class to handle cryptographic operations of our chat App. Handles Public-Key Encryption and Digital Signatures of messages. From 5a5ac3b656f40abc2d3652268356c8ba341d4eb4 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:23:07 +0200 Subject: [PATCH 33/56] Implement Sending Public Text Messages Note: We moved the frontend over so no atomic commits possible. - Implemented basic Backend - Made first Text Messages Possible Co-Authored-By: Erri4 <118684880+erri4@users.noreply.github.com> --- .gitignore | 4 + src/frontend/frontend/states/chat_state.py | 87 -------- src/{frontend => witty_wisterias}/.gitignore | 0 .../assets/favicon.ico | Bin .../backend}/__init__.py | 0 src/witty_wisterias/backend/backend.py | 99 +++++++++ src/witty_wisterias/backend/cryptography.py | 158 +++++++++++++++ .../{modules => backend}/database.py | 40 ++-- .../{modules => backend}/exceptions.py | 4 + .../{modules => backend}/message_format.py | 62 +++--- .../frontend}/__init__.py | 0 .../frontend/components}/__init__.py | 0 .../frontend/components/chat_bubble.py | 0 .../frontend/components/chatapp.py | 3 +- .../frontend/components/image_button.py | 0 .../frontend/components/sidebar.py | 0 .../frontend/components/text_button.py | 0 .../frontend/frontend.py | 3 +- .../{ => frontend/states}/__init__.py | 0 .../frontend/states/chat_state.py | 188 ++++++++++++++++++ src/witty_wisterias/modules/__init__.py | 0 .../requirements.txt | 0 src/{frontend => witty_wisterias}/rxconfig.py | 0 23 files changed, 517 insertions(+), 131 deletions(-) delete mode 100644 src/frontend/frontend/states/chat_state.py rename src/{frontend => witty_wisterias}/.gitignore (100%) rename src/{frontend => witty_wisterias}/assets/favicon.ico (100%) rename src/{frontend/frontend => witty_wisterias/backend}/__init__.py (100%) create mode 100644 src/witty_wisterias/backend/backend.py create mode 100644 src/witty_wisterias/backend/cryptography.py rename src/witty_wisterias/{modules => backend}/database.py (90%) rename src/witty_wisterias/{modules => backend}/exceptions.py (50%) rename src/witty_wisterias/{modules => backend}/message_format.py (56%) rename src/{frontend/frontend/components => witty_wisterias/frontend}/__init__.py (100%) rename src/{frontend/frontend/states => witty_wisterias/frontend/components}/__init__.py (100%) rename src/{frontend => witty_wisterias}/frontend/components/chat_bubble.py (100%) rename src/{frontend => witty_wisterias}/frontend/components/chatapp.py (87%) rename src/{frontend => witty_wisterias}/frontend/components/image_button.py (100%) rename src/{frontend => witty_wisterias}/frontend/components/sidebar.py (100%) rename src/{frontend => witty_wisterias}/frontend/components/text_button.py (100%) rename src/{frontend => witty_wisterias}/frontend/frontend.py (90%) rename src/witty_wisterias/{ => frontend/states}/__init__.py (100%) create mode 100644 src/witty_wisterias/frontend/states/chat_state.py delete mode 100644 src/witty_wisterias/modules/__init__.py rename src/{frontend => witty_wisterias}/requirements.txt (100%) rename src/{frontend => witty_wisterias}/rxconfig.py (100%) diff --git a/.gitignore b/.gitignore index 9299eb99..da547479 100644 --- a/.gitignore +++ b/.gitignore @@ -208,3 +208,7 @@ __marimo__/ # DS Store (macOS) .DS_Store + +# Reflex +.web +.states diff --git a/src/frontend/frontend/states/chat_state.py b/src/frontend/frontend/states/chat_state.py deleted file mode 100644 index 05c5d32f..00000000 --- a/src/frontend/frontend/states/chat_state.py +++ /dev/null @@ -1,87 +0,0 @@ -import asyncio -import io -import random -from typing import TypedDict - -import reflex as rx -import requests -from PIL import Image - - -class Message(TypedDict): - """A message in the chat application.""" - - message: str | Image.Image - user_id: str - user_name: str - user_profile_image: str | None - own_message: bool - is_image_message: bool - - -class ChatState(rx.State): - """The Chat app state, used to handle Messages.""" - - messages: list[Message] = rx.field(default_factory=list) - - @rx.event - def send_text(self, form_data: dict[str, str]) -> None: - """ - Reflex Event when a text message is sent. - - Args: - form_data (dict[str, str]): The form data containing the message in the `message` field. - """ - message = form_data.get("message", "").strip() - if message: - self.messages.append( - Message( - message=message, - user_id="ME", - user_name="User Name", - user_profile_image=None, - own_message=True, - is_image_message=False, - ) - ) - - @rx.event - def send_image(self, form_data: dict[str, str]) -> None: - """ - Reflex Event when an image message is sent. - - Args: - form_data (dict[str, str]): The form data containing the image URL in the `message` field. - """ - message = form_data.get("message", "").strip() - if message: - response = requests.get(message, timeout=10) - img = Image.open(io.BytesIO(response.content)) - self.messages.append( - Message( - message=img, - user_id="ME", - user_name="User Name", - user_profile_image=None, - own_message=True, - is_image_message=True, - ) - ) - - @rx.event(background=True) - async def check_messages(self) -> None: - """Reflex Background Check for new messages.""" - while True: - # Simulate checking for new messages - await asyncio.sleep(random.randint(5, 10)) - async with self: - self.messages.append( - Message( - message="New message from background task", - user_id="BOT", - user_name="Bot", - user_profile_image=None, - own_message=False, - is_image_message=False, - ) - ) diff --git a/src/frontend/.gitignore b/src/witty_wisterias/.gitignore similarity index 100% rename from src/frontend/.gitignore rename to src/witty_wisterias/.gitignore diff --git a/src/frontend/assets/favicon.ico b/src/witty_wisterias/assets/favicon.ico similarity index 100% rename from src/frontend/assets/favicon.ico rename to src/witty_wisterias/assets/favicon.ico diff --git a/src/frontend/frontend/__init__.py b/src/witty_wisterias/backend/__init__.py similarity index 100% rename from src/frontend/frontend/__init__.py rename to src/witty_wisterias/backend/__init__.py diff --git a/src/witty_wisterias/backend/backend.py b/src/witty_wisterias/backend/backend.py new file mode 100644 index 00000000..b2c39515 --- /dev/null +++ b/src/witty_wisterias/backend/backend.py @@ -0,0 +1,99 @@ +import base64 +import json +import zlib + +from .database import Database +from .exceptions import InvalidDataError +from .message_format import MessageFormat + + +class Backend: + """ + Base class for the backend. + This class should be inherited by all backends. + """ + + @staticmethod + def decode(stack: str) -> list[MessageFormat]: + """ + Decode a base64-encoded, compressed JSON string into a list of MessageFormat objects. + + Args: + stack (str): The base64-encoded, compressed JSON string representing a stack of messages. + + Returns: + list[MessageFormat]: A list of MessageFormat objects reconstructed from the decoded data. + """ + if not stack: + return [] + compressed = base64.b64decode(stack.encode("utf-8")) + # Decompress + json_str = zlib.decompress(compressed).decode("utf-8") + # Deserialize JSON back to list + json_stack = json.loads(json_str) + # Convert each JSON object back to MessageFormat + return [MessageFormat.from_json(message) for message in json_stack] + + @staticmethod + def encode(stack: list[MessageFormat]) -> str: + """ + Encode a list of MessageFormat objects into a base64-encoded, compressed JSON string. + + Args: + stack (list[MessageFormat]): A list of MessageFormat objects to be encoded. + + Returns: + str: A base64-encoded, compressed JSON string representing the list of messages. + """ + # Convert each MessageFormat object to JSON + dumped_stack = [message.to_json() for message in stack] + # Serialize the list to JSON + json_str = json.dumps(dumped_stack) + # Compress the JSON string + compressed = zlib.compress(json_str.encode("utf-8")) + # Encode to base64 for safe transmission + return base64.b64encode(compressed).decode("utf-8") + + @staticmethod + def send_public_text(message: MessageFormat) -> None: + """ + Send a public test message to the Database. + + Args: + message (MessageFormat): The message to be sent, containing senderID, event type, content, and signing key. + """ + if not (message.sender_id and message.event_type and message.content and message.signing_key): + raise InvalidDataError("MessageFormat is not complete") + + queried_data = Backend.decode(Database.query_data()) + + # To do: Signing Messages should be done here + # signed_message = Cryptography.sign_message( + # message.content, message.signing_key + # ) + public_message = MessageFormat( + sender_id=message.sender_id, + event_type=message.event_type, + content=message.content, + timestamp=message.timestamp, + ) + + queried_data.append(public_message) + + Database.upload_data(Backend.encode(queried_data)) + + @staticmethod + def read_public_text() -> list[MessageFormat]: + """ + Read public text messages. + This method should be overridden by the backend. + """ + decoded_data = Backend.decode(Database.query_data()) + + verified_messaged: list[MessageFormat] = [] + for message in decoded_data: + # TODO: Signature verification should be done here + # Note: We ignore copy Linting Error because we will modify the message later, when we verify it + verified_messaged.append(message) # noqa: PERF402 + + return verified_messaged diff --git a/src/witty_wisterias/backend/cryptography.py b/src/witty_wisterias/backend/cryptography.py new file mode 100644 index 00000000..d9649802 --- /dev/null +++ b/src/witty_wisterias/backend/cryptography.py @@ -0,0 +1,158 @@ +import base64 +import random + +from nacl.public import Box, PrivateKey, PublicKey +from nacl.signing import SigningKey, VerifyKey + + +class Cryptography: + """ + A class to handle cryptographic operations of our chat App. + Handles Public-Key Encryption and Digital Signatures of messages. + """ + + @staticmethod + def generate_random_user_id() -> str: + """ + Generates a random UserID which will be 48bit (fits exactly 2 RGB pixels). + Note: The chance that two users having the same UserID is 1 in 281,474,976,710,656. + So for this CodeJam, we can safely assume that this will never happen and don't check for Duplicates. + + Returns: + str: A random 48-bit UserID encoded in base64. + """ + # Generate random 48-bit integer (0 to 2^48 - 1) + user_id_bits = random.getrandbits(48) + # Convert to bytes (6 bytes for 48 bits) + user_id_bytes = user_id_bits.to_bytes(6, byteorder="big") + # Encode to base64 + return base64.b64encode(user_id_bytes).decode("utf-8") + + @staticmethod + def generate_signing_key_pair() -> tuple[str, str]: + """ + Generates a new base64-encoded signing-verify key pair for signing messages. + + Returns: + str: The base64-encoded signing key. + str: The base64-encoded verify key. + """ + # Generate a new signing key + nacl_signing_key = SigningKey.generate() + nacl_verify_key = nacl_signing_key.verify_key + # Encode the keys in base64 + encoded_signing_key = base64.b64encode(nacl_signing_key.encode()).decode("utf-8") + encoded_verify_key = base64.b64encode(nacl_verify_key.encode()).decode("utf-8") + # Return the signing key and its verify key in base64 encoding + return encoded_signing_key, encoded_verify_key + + @staticmethod + def generate_encryption_key_pair() -> tuple[str, str]: + """ + Generates a new base64-encoded private-public key pair. + + Returns: + str: The base64-encoded private key. + str: The base64-encoded public key. + """ + # Generate a new private key + nacl_private_key = PrivateKey.generate() + nacl_public_key = nacl_private_key.public_key + # Encode the keys in base64 + encoded_private_key = base64.b64encode(nacl_private_key.encode()).decode("utf-8") + encoded_public_key = base64.b64encode(nacl_public_key.encode()).decode("utf-8") + # Return the private key and its public key in base64 encoding + return encoded_private_key, encoded_public_key + + @staticmethod + def sign_message(message: str, signing_key: str) -> str: + """ + Signs a message using the provided signing key. + + Args: + message (str): The message to sign. + signing_key (str): The base64-encoded signing key. + + Returns: + str: The signed, base64-encoded message. + """ + # Decode the signing key from base64 + signing_key_bytes = base64.b64decode(signing_key) + # Create a SigningKey object + nacl_signing_key = SigningKey(signing_key_bytes) + # Sign the message + signed_message = nacl_signing_key.sign(message.encode("utf-8")) + return base64.b64encode(signed_message).decode("utf-8") + + @staticmethod + def verify_message(signed_message: str, verify_key: str) -> str: + """ + Verifies a signed message using the provided verify key. + + Args: + signed_message (str): The signed, base64-encoded message. + verify_key (str): The base64-encoded verify key. + + Returns: + str: The original message if verification is successful. + + Raises: + ValueError: If the verification fails. + """ + # Decode the signed message and verify key from base64 + signed_message_bytes = base64.b64decode(signed_message) + verify_key_bytes = base64.b64decode(verify_key) + # Create a VerifyKey object + nacl_verify_key = VerifyKey(verify_key_bytes) + # Verify the signed message + try: + verified_message: bytes = nacl_verify_key.verify(signed_message_bytes) + return verified_message.decode("utf-8") + except Exception as e: + raise ValueError("Verification failed") from e + + @staticmethod + def encrypt_message(message: str, sender_private_key: str, recipient_public_key: str) -> str: + """ + Encrypts a message using the recipient's public key and the sender's private key. + + Args: + message (str): The message to encrypt. + sender_private_key (str): The sender's private key in base64 encoding. + recipient_public_key (str): The recipient's public key in base64 encoding. + + Returns: + str: The encrypted, base64-encoded message. + """ + # Decode the keys from base64 + sender_private_key_bytes = base64.b64decode(sender_private_key) + recipient_public_key_bytes = base64.b64decode(recipient_public_key) + # Create the Box for encryption + nacl_box = Box(PrivateKey(sender_private_key_bytes), PublicKey(recipient_public_key_bytes)) + # Encrypt the message + encrypted_message = nacl_box.encrypt(message.encode("utf-8")) + return base64.b64encode(encrypted_message).decode("utf-8") + + @staticmethod + def decrypt_message(encrypted_message: str, recipient_private_key: str, sender_public_key: str) -> str: + """ + Decrypts a message using the recipient's private key and the sender's public key. + + Args: + encrypted_message (str): The encrypted, base64-encoded message. + recipient_private_key (str): The recipient's private key in base64 encoding. + sender_public_key (str): The sender's public key in base64 encoding. + + Returns: + str: The decrypted message. + """ + # Decode the keys from base64 + recipient_private_key_bytes = base64.b64decode(recipient_private_key) + sender_public_key_bytes = base64.b64decode(sender_public_key) + # Create the Box for decryption + nacl_box = Box(PrivateKey(recipient_private_key_bytes), PublicKey(sender_public_key_bytes)) + # Decode the encrypted message from base64 + encrypted_message_bytes = base64.b64decode(encrypted_message) + # Decrypt the message + decrypted_message: bytes = nacl_box.decrypt(encrypted_message_bytes) + return decrypted_message.decode("utf-8") diff --git a/src/witty_wisterias/modules/database.py b/src/witty_wisterias/backend/database.py similarity index 90% rename from src/witty_wisterias/modules/database.py rename to src/witty_wisterias/backend/database.py index 72ae7649..c8ee65bc 100644 --- a/src/witty_wisterias/modules/database.py +++ b/src/witty_wisterias/backend/database.py @@ -12,12 +12,15 @@ from .exceptions import InvalidResponseError +# Global HTTP Session for the Database +HTTP_SESSION = httpx.Client() + # Image Hoster URL and API Endpoints HOSTER_URL = "https://freeimghost.net/" UPLOAD_URL = HOSTER_URL + "upload" JSON_URL = HOSTER_URL + "json" # Search Term used to query for our images (and name our files) -FILE_SEARCH_TERM = "WittyWisterias" +FILE_SEARCH_TERM = "WittyWisterias v6" def search_url(query: str) -> str: @@ -33,9 +36,6 @@ class Database: We will later be able to query for the latest messages via https://freeimghost.net/search/images/?q={SearchTerm} """ - def __init__(self) -> None: - self.session = httpx.Client() - @staticmethod def extract_timestamp(url: str) -> float: """ @@ -92,7 +92,8 @@ def base64_to_image(data: bytes) -> bytes: raise ValueError("File Size exceeds limit of 20MB, shrink the Image Stack.") return image_bytes - def get_configuration_data(self) -> str: + @staticmethod + def get_configuration_data() -> str: """ Fetches the necessary configuration data for uploading images to the database. @@ -103,7 +104,7 @@ def get_configuration_data(self) -> str: InvalidResponseError: If the configuration data cannot be fetched or the auth token is not found. """ # Getting necessary configuration data for upload - config_response = self.session.get(UPLOAD_URL) + config_response = HTTP_SESSION.get(UPLOAD_URL) # Check if the response is successful if config_response.status_code != 200: raise InvalidResponseError("Failed to fetch configuration data from the image hosting service.") @@ -116,7 +117,8 @@ def get_configuration_data(self) -> str: # Extracting auth token return match.group(1) - def upload_image(self, image_bytes: bytes) -> None: + @staticmethod + def upload_image(image_bytes: bytes) -> None: """ Uploads the image bytes to the Database/Image Hosting Service. @@ -131,12 +133,12 @@ def upload_image(self, image_bytes: bytes) -> None: # Convert to UTC Timestamp utc_timestamp = utc_time.timestamp() - auth_token = self.get_configuration_data() + auth_token = Database.get_configuration_data() # Hash the image bytes to create a checksum using xxHash64 (Specified by Image Hosting Service) checksum = xxhash.xxh64(image_bytes).hexdigest() # Post Image to Image Hosting Service - response = self.session.post( + response = HTTP_SESSION.post( url=JSON_URL, files={ "source": (f"{FILE_SEARCH_TERM}_{utc_timestamp}.png", image_bytes, "image/png"), @@ -155,7 +157,8 @@ def upload_image(self, image_bytes: bytes) -> None: if response.status_code != 200: raise InvalidResponseError("Failed to upload image to the image hosting service.") - def query_data(self) -> str: + @staticmethod + def query_data() -> str: """ Queries the latest data from the database. @@ -166,7 +169,7 @@ def query_data(self) -> str: InvalidResponseError: If the query fails or the response is not as expected. """ # Query all images with the search term "WittyWisterias" from the image hosting service - response = self.session.get(search_url("WittyWisterias")) + response = HTTP_SESSION.get(search_url("WittyWisterias")) # Check if the response is successful if response.status_code != 200: raise InvalidResponseError("Failed to query latest image from the image hosting service.") @@ -177,12 +180,12 @@ def query_data(self) -> str: image_links = [img.get("src") for img in soup.find_all("img") if HOSTER_URL in img.get("src")] # Sort the image elements by the timestamp in the filename (in the link) (newest first) - sorted_image_links: list[str] = sorted(image_links, key=self.extract_timestamp, reverse=True) + sorted_image_links: list[str] = sorted(image_links, key=Database.extract_timestamp, reverse=True) # Find the first image link that contains our validation header and return its pixel byte data for image_link in sorted_image_links: # Fetch the image content - image_content = self.session.get(image_link).content + image_content = HTTP_SESSION.get(image_link).content # Get the byte content of the image without the PNG Image File Header image_stream = BytesIO(image_content) @@ -199,10 +202,11 @@ def query_data(self) -> str: decoded_data: str = no_padding_data.decode("utf-8", errors="ignore") return decoded_data - # If no valid image is found, raise an error - raise InvalidResponseError("No valid image found in the response.") + # If no valid image is found, return an empty string + return "" - def upload_data(self, data: str) -> None: + @staticmethod + def upload_data(data: str) -> None: """ Uploads string encoded data as an image to the database hosted on the Image Hosting Service. @@ -216,9 +220,9 @@ def upload_data(self, data: str) -> None: # Convert the string data to bytes bytes_data = data.encode("utf-8") # Convert the bytes data to an Image which contains encoded data - image_bytes = self.base64_to_image(bytes_data) + image_bytes = Database.base64_to_image(bytes_data) # Upload the image bytes to the Image Hosting Service - self.upload_image(image_bytes) + Database.upload_image(image_bytes) if __name__ == "__main__": diff --git a/src/witty_wisterias/modules/exceptions.py b/src/witty_wisterias/backend/exceptions.py similarity index 50% rename from src/witty_wisterias/modules/exceptions.py rename to src/witty_wisterias/backend/exceptions.py index 9dc5f0e8..84defcc7 100644 --- a/src/witty_wisterias/modules/exceptions.py +++ b/src/witty_wisterias/backend/exceptions.py @@ -1,2 +1,6 @@ class InvalidResponseError(Exception): """Raise for invalid responses from the server.""" + + +class InvalidDataError(Exception): + """Raise for invalid data provided to the server.""" diff --git a/src/witty_wisterias/modules/message_format.py b/src/witty_wisterias/backend/message_format.py similarity index 56% rename from src/witty_wisterias/modules/message_format.py rename to src/witty_wisterias/backend/message_format.py index 43d653fa..f6d2b99d 100644 --- a/src/witty_wisterias/modules/message_format.py +++ b/src/witty_wisterias/backend/message_format.py @@ -1,4 +1,5 @@ import json +from dataclasses import dataclass, field from enum import Enum, auto from typing import TypedDict @@ -22,34 +23,45 @@ class MessageJson(TypedDict): This is used for serialization and deserialization of messages. """ - header: dict[str, str | None] - body: dict[str, str | dict[str, str]] - previous_messages: list["MessageFormat"] + header: dict[str, str | float | None] + body: dict[str, str | dict[str, str | None]] +@dataclass +class ExtraEventInfo: + """Storage for extra information related to an event.""" + + user_name: str | None = field(default=None) + user_image: str | None = field(default=None) + + def to_dict(self) -> dict[str, str | None]: + """Convert the extra event info to a dictionary.""" + return { + "user_name": self.user_name, + "user_image": self.user_image, + } + + @staticmethod + def from_json(data: dict[str, str | None]) -> "ExtraEventInfo": + """Deserialize a JSON string into an ExtraEventInfo object.""" + return ExtraEventInfo(user_name=data.get("user_name", ""), user_image=data.get("user_image", "")) + + +@dataclass class MessageFormat: """ Defines the standard structure for messages in the system. Supports serialization/deserialization for storage in images. """ - def __init__( - self, - sender_id: str, - content: str, - event_type: EventType, - receiver_id: str | None = None, - public_key: str | None = None, - extra_event_info: dict[str, str] | None = None, - previous_messages: list["MessageFormat"] | None = None, - ) -> None: - self.sender_id = sender_id - self.receiver_id = receiver_id - self.event_type = event_type - self.public_key = public_key - self.content = content - self.extra_event_info = extra_event_info or {} - self.previous_messages = previous_messages or [] + sender_id: str + event_type: EventType + content: str + timestamp: float + receiver_id: str = field(default="None") + signing_key: str = field(default="") + public_key: str = field(default="") + extra_event_info: ExtraEventInfo = field(default_factory=ExtraEventInfo) def to_dict(self) -> MessageJson: """Convert the message into a Python dictionary.""" @@ -58,10 +70,11 @@ def to_dict(self) -> MessageJson: "sender_id": self.sender_id, "receiver_id": self.receiver_id, "event_type": self.event_type.name, + "signing_key": self.signing_key, "public_key": self.public_key, + "timestamp": self.timestamp, }, - "body": {"content": self.content, "extra_event_info": self.extra_event_info}, - "previous_messages": self.previous_messages, + "body": {"content": self.content, "extra_event_info": self.extra_event_info.to_dict()}, } def to_json(self) -> str: @@ -76,8 +89,9 @@ def from_json(data: str) -> "MessageFormat": sender_id=obj["header"]["sender_id"], receiver_id=obj["header"].get("receiver_id"), event_type=EventType[obj["header"]["event_type"]], + signing_key=obj["header"].get("signing_key"), public_key=obj["header"].get("public_key"), + timestamp=obj["header"]["timestamp"], content=obj["body"]["content"], - extra_event_info=obj["body"].get("extra_event_info", {}), - previous_messages=obj.get("previous_messages", []), + extra_event_info=ExtraEventInfo.from_json(obj["body"].get("extra_event_info", {})), ) diff --git a/src/frontend/frontend/components/__init__.py b/src/witty_wisterias/frontend/__init__.py similarity index 100% rename from src/frontend/frontend/components/__init__.py rename to src/witty_wisterias/frontend/__init__.py diff --git a/src/frontend/frontend/states/__init__.py b/src/witty_wisterias/frontend/components/__init__.py similarity index 100% rename from src/frontend/frontend/states/__init__.py rename to src/witty_wisterias/frontend/components/__init__.py diff --git a/src/frontend/frontend/components/chat_bubble.py b/src/witty_wisterias/frontend/components/chat_bubble.py similarity index 100% rename from src/frontend/frontend/components/chat_bubble.py rename to src/witty_wisterias/frontend/components/chat_bubble.py diff --git a/src/frontend/frontend/components/chatapp.py b/src/witty_wisterias/frontend/components/chatapp.py similarity index 87% rename from src/frontend/frontend/components/chatapp.py rename to src/witty_wisterias/frontend/components/chatapp.py index bb2c72b0..8a22956f 100644 --- a/src/frontend/frontend/components/chatapp.py +++ b/src/witty_wisterias/frontend/components/chatapp.py @@ -14,7 +14,8 @@ def chat_app() -> rx.Component: ChatState.messages, lambda message: chat_bubble_component( message["message"], - message["user_name"], + # Use UserID as fallback for Username + rx.cond(message["user_name"], message["user_name"], message["user_id"]), message["user_profile_image"], message["own_message"], message["is_image_message"], diff --git a/src/frontend/frontend/components/image_button.py b/src/witty_wisterias/frontend/components/image_button.py similarity index 100% rename from src/frontend/frontend/components/image_button.py rename to src/witty_wisterias/frontend/components/image_button.py diff --git a/src/frontend/frontend/components/sidebar.py b/src/witty_wisterias/frontend/components/sidebar.py similarity index 100% rename from src/frontend/frontend/components/sidebar.py rename to src/witty_wisterias/frontend/components/sidebar.py diff --git a/src/frontend/frontend/components/text_button.py b/src/witty_wisterias/frontend/components/text_button.py similarity index 100% rename from src/frontend/frontend/components/text_button.py rename to src/witty_wisterias/frontend/components/text_button.py diff --git a/src/frontend/frontend/frontend.py b/src/witty_wisterias/frontend/frontend.py similarity index 90% rename from src/frontend/frontend/frontend.py rename to src/witty_wisterias/frontend/frontend.py index 80f37121..ead0fd31 100644 --- a/src/frontend/frontend/frontend.py +++ b/src/witty_wisterias/frontend/frontend.py @@ -5,7 +5,7 @@ from frontend.states.chat_state import ChatState -@rx.page(on_load=ChatState.check_messages) +@rx.page(on_load=ChatState.startup_event) def index() -> rx.Component: """The main page of the chat application, which includes the sidebar and chat app components.""" return rx.hstack( @@ -23,6 +23,7 @@ def index() -> rx.Component: ], style={ "font_family": "Bitcount Prop Single", + "background_color": "white", }, ) app.add_page(index) diff --git a/src/witty_wisterias/__init__.py b/src/witty_wisterias/frontend/states/__init__.py similarity index 100% rename from src/witty_wisterias/__init__.py rename to src/witty_wisterias/frontend/states/__init__.py diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py new file mode 100644 index 00000000..98228135 --- /dev/null +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -0,0 +1,188 @@ +import asyncio +import io +import json +from collections.abc import AsyncGenerator +from datetime import UTC, datetime +from typing import Literal, TypedDict, cast + +import reflex as rx +import requests +from backend.backend import Backend +from backend.cryptography import Cryptography +from backend.message_format import EventType, MessageFormat +from PIL import Image + + +class Message(TypedDict): + """A message in the chat application.""" + + message: str | Image.Image + user_id: str + user_name: str + user_profile_image: str | None + own_message: bool + is_image_message: bool + timestamp: float + + +class ChatState(rx.State): + """The Chat app state, used to handle Messages.""" + + # List of Messages + messages: list[Message] = rx.field(default_factory=list) + # Own User Data + user_id: str = rx.LocalStorage("", name="user_id", sync=True) + user_name: str = rx.LocalStorage("", name="user_name", sync=True) + user_profile_image: str | None = rx.LocalStorage(None, name="user_profile_image", sync=True) + # Own Signing key and Others Verify Keys for Global Chat + signing_key: str = rx.LocalStorage("", name="signing_key", sync=True) + verify_keys_storage: str = rx.LocalStorage("{}", name="verify_keys_storage", sync=True) + # Own Private Keys and Others Public Keys for Private Chats + private_keys_storage: str = rx.LocalStorage("{}", name="private_keys_storage", sync=True) + public_keys_storage: str = rx.LocalStorage("{}", name="public_keys_storage", sync=True) + + # Verify Keys Storage Helpers + def get_key_storage(self, storage_name: Literal["verify_keys", "private_keys", "public_keys"]) -> dict[str, str]: + """ + Get the key storage for the specified storage name. + + Args: + storage_name (Literal["verify_keys", "private_keys", "public_keys"]): The name of the storage to retrieve. + + Returns: + dict[str, str]: A dictionary containing the keys and their corresponding values. + """ + storage = self.__getattribute__(f"{storage_name}_storage") + return cast("dict[str, str]", storage) + + def dump_key_storage( + self, storage_name: Literal["verify_keys", "private_keys", "public_keys"], value: dict[str, str] + ) -> None: + """ + Dump the key storage to the specified storage name. + + Args: + storage_name (Literal["verify_keys", "private_keys", "public_keys"]): The name of the storage to dump to. + value (dict[str, str]): The dictionary containing the userIDs and their Keys. + """ + self.__setattr__(f"{storage_name}_storage", json.dumps(value)) + + def add_key_storage( + self, storage_name: Literal["verify_keys", "private_keys", "public_keys"], user_id: str, verify_key: str + ) -> None: + """ + Add a userID and its corresponding key to the specified storage. + + Args: + storage_name (Literal["verify_keys", "private_keys", "public_keys"]): The name of the storage to add to. + user_id (str): The user ID to add. + verify_key (str): The key to associate with the user ID. + """ + current_keys = self.get_key_storage(storage_name) + current_keys[user_id] = verify_key + self.dump_key_storage(storage_name, current_keys) + + @rx.event + def send_text(self, form_data: dict[str, str]) -> None: + """ + Reflex Event when a text message is sent. + + Args: + form_data (dict[str, str]): The form data containing the message in the `message` field. + """ + message = form_data.get("message", "").strip() + if message: + message_timestamp = datetime.now(UTC).timestamp() + # Appending new own message + self.messages.append( + Message( + message=message, + user_id=self.user_id, + user_name=self.user_name, + user_profile_image=self.user_profile_image, + own_message=True, + is_image_message=False, + timestamp=message_timestamp, + ) + ) + # Posting message to backend + message_format = MessageFormat( + sender_id=self.user_id, + event_type=EventType.PUBLIC_TEXT, + content=message, + timestamp=message_timestamp, + signing_key=self.signing_key, + ) + Backend.send_public_text(message_format) + + @rx.event + def send_image(self, form_data: dict[str, str]) -> None: + """ + Reflex Event when an image message is sent. + + Args: + form_data (dict[str, str]): The form data containing the image URL in the `message` field. + """ + message = form_data.get("message", "").strip() + if message: + # Temporary + response = requests.get(message, timeout=10) + img = Image.open(io.BytesIO(response.content)) + + message_timestamp = datetime.now(UTC).timestamp() + self.messages.append( + Message( + message=img, + user_id=self.user_id, + user_name=self.user_name, + user_profile_image=self.user_profile_image, + own_message=True, + is_image_message=True, + timestamp=message_timestamp, + ) + ) + + @rx.event(background=True) + async def check_messages(self) -> None: + """Reflex Background Check for new messages.""" + while True: + async with self: + for message in Backend.read_public_text(): + # Check if the message is already in the chat using timestamp + message_exists = any( + msg["timestamp"] == message.timestamp and msg["user_id"] == message.sender_id + for msg in self.messages + ) + + # Check if message is not already in the chat + if not message_exists: + self.messages.append( + Message( + message=message.content, + user_id=message.sender_id, + user_name=message.extra_event_info.user_name, + user_profile_image=message.extra_event_info.user_image, + own_message=self.user_id == message.sender_id, + is_image_message=False, + timestamp=message.timestamp, + ) + ) + + # Wait for 5 seconds before checking for new messages again to avoid excessive load + await asyncio.sleep(5) + + @rx.event + async def startup_event(self) -> AsyncGenerator[None, None]: + """Reflex Event that is called when the app starts up.""" + # Start Message Checking Background Task + yield ChatState.check_messages + + # Initialize user_id if not already set + if not self.user_id: + # Simulate fetching a user ID from an external source + self.user_id = Cryptography.generate_random_user_id() + + # Generate new Signing Key Pair if not set + if not self.signing_key or self.user_id not in self.get_key_storage("verify_keys"): + self.signing_key, verify_key = Cryptography.generate_signing_key_pair() + self.add_key_storage("verify_keys", self.user_id, verify_key) diff --git a/src/witty_wisterias/modules/__init__.py b/src/witty_wisterias/modules/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/frontend/requirements.txt b/src/witty_wisterias/requirements.txt similarity index 100% rename from src/frontend/requirements.txt rename to src/witty_wisterias/requirements.txt diff --git a/src/frontend/rxconfig.py b/src/witty_wisterias/rxconfig.py similarity index 100% rename from src/frontend/rxconfig.py rename to src/witty_wisterias/rxconfig.py From 62e54544735c9458c4018657e6b40705931977f2 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:20:59 +0200 Subject: [PATCH 34/56] Add Public Chat Message Signing --- src/witty_wisterias/backend/backend.py | 95 ++++++++++++++----- .../{cryptography.py => cryptographer.py} | 2 +- src/witty_wisterias/backend/database.py | 8 +- src/witty_wisterias/backend/message_format.py | 6 ++ .../frontend/states/chat_state.py | 9 +- 5 files changed, 85 insertions(+), 35 deletions(-) rename src/witty_wisterias/backend/{cryptography.py => cryptographer.py} (99%) diff --git a/src/witty_wisterias/backend/backend.py b/src/witty_wisterias/backend/backend.py index b2c39515..7072b733 100644 --- a/src/witty_wisterias/backend/backend.py +++ b/src/witty_wisterias/backend/backend.py @@ -1,12 +1,47 @@ import base64 import json import zlib +from dataclasses import asdict, dataclass, field +from .cryptographer import Cryptographer from .database import Database from .exceptions import InvalidDataError from .message_format import MessageFormat +@dataclass +class UploadStack: + """The UploadStack class is used to store the data that will be uploaded to the Database.""" + + profile_image_stack: dict[str, str] = field(default_factory=dict) + verify_keys_stack: dict[str, str] = field(default_factory=dict) + public_keys_stack: dict[str, str] = field(default_factory=dict) + message_stack: list[MessageFormat | str] = field(default_factory=list) + + @staticmethod + def from_json(data: str) -> "UploadStack": + """ + Deserialize a JSON string into an UploadStack object. + + Args: + data (str): The JSON string to deserialize. + + Returns: + UploadStack: An instance of UploadStack with the deserialized data. + """ + json_data = json.loads(data) + return UploadStack( + profile_image_stack=json_data.get("profile_image_stack", {}), + verify_keys_stack=json_data.get("verify_keys_stack", {}), + public_keys_stack=json_data.get("public_keys_stack", {}), + message_stack=[ + MessageFormat.from_json(message) + for message in json_data.get("message_stack", []) + if isinstance(message, str) + ], + ) + + class Backend: """ Base class for the backend. @@ -14,45 +49,45 @@ class Backend: """ @staticmethod - def decode(stack: str) -> list[MessageFormat]: + def decode(encoded_stack: str) -> UploadStack: """ Decode a base64-encoded, compressed JSON string into a list of MessageFormat objects. Args: - stack (str): The base64-encoded, compressed JSON string representing a stack of messages. + encoded_stack (str): The base64-encoded, compressed JSON string representing a stack of messages. Returns: list[MessageFormat]: A list of MessageFormat objects reconstructed from the decoded data. """ - if not stack: - return [] - compressed = base64.b64decode(stack.encode("utf-8")) + if not encoded_stack: + return UploadStack() + compressed_stack = base64.b64decode(encoded_stack.encode("utf-8")) # Decompress - json_str = zlib.decompress(compressed).decode("utf-8") - # Deserialize JSON back to list - json_stack = json.loads(json_str) - # Convert each JSON object back to MessageFormat - return [MessageFormat.from_json(message) for message in json_stack] + string_stack = zlib.decompress(compressed_stack).decode("utf-8") + # Convert String Stack to UploadStack object + return UploadStack.from_json(string_stack) @staticmethod - def encode(stack: list[MessageFormat]) -> str: + def encode(upload_stack: UploadStack) -> str: """ Encode a list of MessageFormat objects into a base64-encoded, compressed JSON string. Args: - stack (list[MessageFormat]): A list of MessageFormat objects to be encoded. + upload_stack (UploadStack): The UploadStack to encode. Returns: str: A base64-encoded, compressed JSON string representing the list of messages. """ # Convert each MessageFormat object to JSON - dumped_stack = [message.to_json() for message in stack] + upload_stack.message_stack = [ + message.to_json() for message in upload_stack.message_stack if isinstance(message, MessageFormat) + ] # Serialize the list to JSON - json_str = json.dumps(dumped_stack) + json_stack = json.dumps(asdict(upload_stack)) # Compress the JSON string - compressed = zlib.compress(json_str.encode("utf-8")) + compressed_stack = zlib.compress(json_stack.encode("utf-8")) # Encode to base64 for safe transmission - return base64.b64encode(compressed).decode("utf-8") + return base64.b64encode(compressed_stack).decode("utf-8") @staticmethod def send_public_text(message: MessageFormat) -> None: @@ -67,18 +102,20 @@ def send_public_text(message: MessageFormat) -> None: queried_data = Backend.decode(Database.query_data()) - # To do: Signing Messages should be done here - # signed_message = Cryptography.sign_message( - # message.content, message.signing_key - # ) + # Append the verify_key to the Upload Stack if not already present + if message.verify_key not in queried_data.verify_keys_stack: + queried_data.verify_keys_stack[message.sender_id] = message.verify_key + + # Sign the message using the Signing Key + signed_message = Cryptographer.sign_message(message.content, message.signing_key) public_message = MessageFormat( sender_id=message.sender_id, event_type=message.event_type, - content=message.content, + content=signed_message, timestamp=message.timestamp, ) - queried_data.append(public_message) + queried_data.message_stack.append(public_message) Database.upload_data(Backend.encode(queried_data)) @@ -91,9 +128,15 @@ def read_public_text() -> list[MessageFormat]: decoded_data = Backend.decode(Database.query_data()) verified_messaged: list[MessageFormat] = [] - for message in decoded_data: - # TODO: Signature verification should be done here - # Note: We ignore copy Linting Error because we will modify the message later, when we verify it - verified_messaged.append(message) # noqa: PERF402 + for message in decoded_data.message_stack: + # Signature Verification + if not isinstance(message, str) and message.sender_id in decoded_data.verify_keys_stack: + verify_key = decoded_data.verify_keys_stack[message.sender_id] + try: + # Verify the message content using the verify key + message.content = Cryptographer.verify_message(message.content, verify_key) + verified_messaged.append(message) + except ValueError: + pass return verified_messaged diff --git a/src/witty_wisterias/backend/cryptography.py b/src/witty_wisterias/backend/cryptographer.py similarity index 99% rename from src/witty_wisterias/backend/cryptography.py rename to src/witty_wisterias/backend/cryptographer.py index d9649802..85cc0b47 100644 --- a/src/witty_wisterias/backend/cryptography.py +++ b/src/witty_wisterias/backend/cryptographer.py @@ -5,7 +5,7 @@ from nacl.signing import SigningKey, VerifyKey -class Cryptography: +class Cryptographer: """ A class to handle cryptographic operations of our chat App. Handles Public-Key Encryption and Digital Signatures of messages. diff --git a/src/witty_wisterias/backend/database.py b/src/witty_wisterias/backend/database.py index c8ee65bc..2f723f5c 100644 --- a/src/witty_wisterias/backend/database.py +++ b/src/witty_wisterias/backend/database.py @@ -13,14 +13,14 @@ from .exceptions import InvalidResponseError # Global HTTP Session for the Database -HTTP_SESSION = httpx.Client() +HTTP_SESSION = httpx.Client(timeout=30) # Image Hoster URL and API Endpoints HOSTER_URL = "https://freeimghost.net/" UPLOAD_URL = HOSTER_URL + "upload" JSON_URL = HOSTER_URL + "json" # Search Term used to query for our images (and name our files) -FILE_SEARCH_TERM = "WittyWisterias v6" +FILE_SEARCH_TERM = "WittyWisteriasV7" def search_url(query: str) -> str: @@ -168,8 +168,8 @@ def query_data() -> str: Raises: InvalidResponseError: If the query fails or the response is not as expected. """ - # Query all images with the search term "WittyWisterias" from the image hosting service - response = HTTP_SESSION.get(search_url("WittyWisterias")) + # Query all images with the FILE_SEARCH_TERM from the image hosting service + response = HTTP_SESSION.get(search_url(FILE_SEARCH_TERM)) # Check if the response is successful if response.status_code != 200: raise InvalidResponseError("Failed to query latest image from the image hosting service.") diff --git a/src/witty_wisterias/backend/message_format.py b/src/witty_wisterias/backend/message_format.py index f6d2b99d..6e8bee63 100644 --- a/src/witty_wisterias/backend/message_format.py +++ b/src/witty_wisterias/backend/message_format.py @@ -60,6 +60,8 @@ class MessageFormat: timestamp: float receiver_id: str = field(default="None") signing_key: str = field(default="") + verify_key: str = field(default="") + private_key: str = field(default="") public_key: str = field(default="") extra_event_info: ExtraEventInfo = field(default_factory=ExtraEventInfo) @@ -71,7 +73,9 @@ def to_dict(self) -> MessageJson: "receiver_id": self.receiver_id, "event_type": self.event_type.name, "signing_key": self.signing_key, + "verify_key": self.verify_key, "public_key": self.public_key, + "private_key": self.private_key, "timestamp": self.timestamp, }, "body": {"content": self.content, "extra_event_info": self.extra_event_info.to_dict()}, @@ -90,7 +94,9 @@ def from_json(data: str) -> "MessageFormat": receiver_id=obj["header"].get("receiver_id"), event_type=EventType[obj["header"]["event_type"]], signing_key=obj["header"].get("signing_key"), + verify_key=obj["header"].get("verify_key"), public_key=obj["header"].get("public_key"), + private_key=obj["header"].get("private_key"), timestamp=obj["header"]["timestamp"], content=obj["body"]["content"], extra_event_info=ExtraEventInfo.from_json(obj["body"].get("extra_event_info", {})), diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index 98228135..84929d7f 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -8,7 +8,7 @@ import reflex as rx import requests from backend.backend import Backend -from backend.cryptography import Cryptography +from backend.cryptographer import Cryptographer from backend.message_format import EventType, MessageFormat from PIL import Image @@ -53,7 +53,7 @@ def get_key_storage(self, storage_name: Literal["verify_keys", "private_keys", " dict[str, str]: A dictionary containing the keys and their corresponding values. """ storage = self.__getattribute__(f"{storage_name}_storage") - return cast("dict[str, str]", storage) + return cast("dict[str, str]", json.loads(storage)) def dump_key_storage( self, storage_name: Literal["verify_keys", "private_keys", "public_keys"], value: dict[str, str] @@ -112,6 +112,7 @@ def send_text(self, form_data: dict[str, str]) -> None: content=message, timestamp=message_timestamp, signing_key=self.signing_key, + verify_key=self.get_key_storage("verify_keys")[self.user_id], ) Backend.send_public_text(message_format) @@ -180,9 +181,9 @@ async def startup_event(self) -> AsyncGenerator[None, None]: # Initialize user_id if not already set if not self.user_id: # Simulate fetching a user ID from an external source - self.user_id = Cryptography.generate_random_user_id() + self.user_id = Cryptographer.generate_random_user_id() # Generate new Signing Key Pair if not set if not self.signing_key or self.user_id not in self.get_key_storage("verify_keys"): - self.signing_key, verify_key = Cryptography.generate_signing_key_pair() + self.signing_key, verify_key = Cryptographer.generate_signing_key_pair() self.add_key_storage("verify_keys", self.user_id, verify_key) From 71e1fc8ae8b172f9c673e304bb852f0b0094daa4 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Thu, 14 Aug 2025 11:20:22 +0200 Subject: [PATCH 35/56] Implement Public Image Messages --- src/witty_wisterias/backend/backend.py | 4 ++-- src/witty_wisterias/frontend/states/chat_state.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/witty_wisterias/backend/backend.py b/src/witty_wisterias/backend/backend.py index 7072b733..95b7c8b8 100644 --- a/src/witty_wisterias/backend/backend.py +++ b/src/witty_wisterias/backend/backend.py @@ -90,7 +90,7 @@ def encode(upload_stack: UploadStack) -> str: return base64.b64encode(compressed_stack).decode("utf-8") @staticmethod - def send_public_text(message: MessageFormat) -> None: + def send_public_message(message: MessageFormat) -> None: """ Send a public test message to the Database. @@ -120,7 +120,7 @@ def send_public_text(message: MessageFormat) -> None: Database.upload_data(Backend.encode(queried_data)) @staticmethod - def read_public_text() -> list[MessageFormat]: + def read_public_messages() -> list[MessageFormat]: """ Read public text messages. This method should be overridden by the backend. diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index 84929d7f..9090f27b 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -114,7 +114,7 @@ def send_text(self, form_data: dict[str, str]) -> None: signing_key=self.signing_key, verify_key=self.get_key_storage("verify_keys")[self.user_id], ) - Backend.send_public_text(message_format) + Backend.send_public_message(message_format) @rx.event def send_image(self, form_data: dict[str, str]) -> None: @@ -131,6 +131,7 @@ def send_image(self, form_data: dict[str, str]) -> None: img = Image.open(io.BytesIO(response.content)) message_timestamp = datetime.now(UTC).timestamp() + # Appending new own message self.messages.append( Message( message=img, @@ -142,13 +143,23 @@ def send_image(self, form_data: dict[str, str]) -> None: timestamp=message_timestamp, ) ) + # Posting message to backend + message_format = MessageFormat( + sender_id=self.user_id, + event_type=EventType.PUBLIC_IMAGE, + content=message, + timestamp=message_timestamp, + signing_key=self.signing_key, + verify_key=self.get_key_storage("verify_keys")[self.user_id], + ) + Backend.send_public_message(message_format) @rx.event(background=True) async def check_messages(self) -> None: """Reflex Background Check for new messages.""" while True: async with self: - for message in Backend.read_public_text(): + for message in Backend.read_public_messages(): # Check if the message is already in the chat using timestamp message_exists = any( msg["timestamp"] == message.timestamp and msg["user_id"] == message.sender_id From adb83fdaf54065f59c9ccc4672adfef46ef5958c Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:21:36 +0200 Subject: [PATCH 36/56] Add Message Progress --- .../frontend/components/sidebar.py | 21 +++-- .../frontend/states/chat_state.py | 33 ++++++-- .../frontend/states/progress_state.py | 77 +++++++++++++++++++ 3 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 src/witty_wisterias/frontend/states/progress_state.py diff --git a/src/witty_wisterias/frontend/components/sidebar.py b/src/witty_wisterias/frontend/components/sidebar.py index 237c965b..ad3354b9 100644 --- a/src/witty_wisterias/frontend/components/sidebar.py +++ b/src/witty_wisterias/frontend/components/sidebar.py @@ -1,5 +1,7 @@ import reflex as rx +from frontend.states.progress_state import ProgressState + def chat_sidebar() -> rx.Component: """Sidebar component for the chat application, which allows users to select different chats.""" @@ -38,14 +40,19 @@ def chat_sidebar() -> rx.Component: size="3", class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", ), - rx.hstack( - rx.avatar(fallback="ID", radius="large", size="3"), - rx.vstack( - rx.text("User Name", size="3"), - rx.text("UserID", size="1", class_name="text-gray-500"), - spacing="0", + rx.vstack( + rx.heading(ProgressState.progress, size="2", class_name="text-gray-500"), + rx.divider(), + rx.hstack( + rx.avatar(fallback="ID", radius="large", size="3"), + rx.vstack( + rx.text("User Name", size="3"), + rx.text("UserID", size="1", class_name="text-gray-500"), + spacing="0", + ), + class_name="mt-1", ), - class_name="mt-auto mb-5", + class_name="mt-auto mb-7 w-full", ), class_name="h-screen bg-gray-50", ), diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index 9090f27b..3478f77c 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -1,7 +1,8 @@ import asyncio import io import json -from collections.abc import AsyncGenerator +import threading +from collections.abc import AsyncGenerator, Generator from datetime import UTC, datetime from typing import Literal, TypedDict, cast @@ -12,6 +13,8 @@ from backend.message_format import EventType, MessageFormat from PIL import Image +from frontend.states.progress_state import ProgressState + class Message(TypedDict): """A message in the chat application.""" @@ -83,7 +86,7 @@ def add_key_storage( self.dump_key_storage(storage_name, current_keys) @rx.event - def send_text(self, form_data: dict[str, str]) -> None: + def send_text(self, form_data: dict[str, str]) -> Generator[None, None]: """ Reflex Event when a text message is sent. @@ -92,6 +95,9 @@ def send_text(self, form_data: dict[str, str]) -> None: """ message = form_data.get("message", "").strip() if message: + # Sending Placebo Progress Bar + yield ProgressState.public_message_progress + message_timestamp = datetime.now(UTC).timestamp() # Appending new own message self.messages.append( @@ -105,6 +111,8 @@ def send_text(self, form_data: dict[str, str]) -> None: timestamp=message_timestamp, ) ) + yield + # Posting message to backend message_format = MessageFormat( sender_id=self.user_id, @@ -114,10 +122,15 @@ def send_text(self, form_data: dict[str, str]) -> None: signing_key=self.signing_key, verify_key=self.get_key_storage("verify_keys")[self.user_id], ) - Backend.send_public_message(message_format) + # Note: We need to use threading here, even if it looks odd. This is because the + # Backend.send_public_message method blocks the UI thread. So we need to run it in a separate thread. + # Something like asyncio.to_thread or similar doesn't work here, not 100% sure why, my best guess is + # that it does not separate from the UI thread properly. So threading is the next best option. + # If you find something better, please update this. + threading.Thread(target=Backend.send_public_message, args=(message_format,), daemon=True).start() @rx.event - def send_image(self, form_data: dict[str, str]) -> None: + def send_image(self, form_data: dict[str, str]) -> Generator[None, None]: """ Reflex Event when an image message is sent. @@ -130,6 +143,9 @@ def send_image(self, form_data: dict[str, str]) -> None: response = requests.get(message, timeout=10) img = Image.open(io.BytesIO(response.content)) + # Sending Placebo Progress Bar + yield ProgressState.public_message_progress + message_timestamp = datetime.now(UTC).timestamp() # Appending new own message self.messages.append( @@ -143,6 +159,8 @@ def send_image(self, form_data: dict[str, str]) -> None: timestamp=message_timestamp, ) ) + yield + # Posting message to backend message_format = MessageFormat( sender_id=self.user_id, @@ -152,7 +170,12 @@ def send_image(self, form_data: dict[str, str]) -> None: signing_key=self.signing_key, verify_key=self.get_key_storage("verify_keys")[self.user_id], ) - Backend.send_public_message(message_format) + # Note: We need to use threading here, even if it looks odd. This is because the + # Backend.send_public_message method blocks the UI thread. So we need to run it in a separate thread. + # Something like asyncio.to_thread or similar doesn't work here, not 100% sure why, my best guess is + # that it does not separate from the UI thread properly. So threading is the next best option. + # If you find something better, please update this. + threading.Thread(target=Backend.send_public_message, args=(message_format,), daemon=True).start() @rx.event(background=True) async def check_messages(self) -> None: diff --git a/src/witty_wisterias/frontend/states/progress_state.py b/src/witty_wisterias/frontend/states/progress_state.py new file mode 100644 index 00000000..4a02df6f --- /dev/null +++ b/src/witty_wisterias/frontend/states/progress_state.py @@ -0,0 +1,77 @@ +import asyncio +from collections.abc import AsyncGenerator + +import reflex as rx + + +class ProgressState(rx.State): + """The Placebo Progress State""" + + # Own User Data + progress: str = "" + + @rx.event(background=True) + async def public_message_progress(self) -> AsyncGenerator[None, None]: + """Simulates the progress of sending a public message with a placebo progress bar.""" + public_message_states = [ + "Pulling Message Stack...", + "Signing Message...", + "Pushing Signed Message...", + "Uploading new Message Stack...", + "", + ] + + for message in public_message_states: + async with self: + # A small text fade-in animation + self.progress = "" + for char in message: + self.progress += char + await asyncio.sleep(0.005) + yield + + # Simulate some processing time (different for each state) + match message: + case "Pulling Message Stack...": + await asyncio.sleep(0.5) + case "Signing Message...": + await asyncio.sleep(0.3) + case "Pushing Signed Message...": + await asyncio.sleep(0.3) + case "Uploading new Message Stack...": + await asyncio.sleep(1) + case "": + pass + + @rx.event(background=True) + async def private_message_progress(self) -> AsyncGenerator[None, None]: + """Simulates the progress of sending a private message with a placebo progress bar.""" + public_message_states = [ + "Pulling Message Stack...", + "Encrypting Message...", + "Pushing Encrypted Message...", + "Uploading new Message Stack...", + "", + ] + + for message in public_message_states: + async with self: + # A small text fade-in animation + self.progress = "" + for char in message: + self.progress += char + await asyncio.sleep(0.005) + yield + + # Simulate some processing time (different for each state) + match message: + case "Pulling Message Stack...": + await asyncio.sleep(0.5) + case "Encrypting Message...": + await asyncio.sleep(0.3) + case "Pushing Encrypted Message...": + await asyncio.sleep(0.3) + case "Uploading new Message Stack...": + await asyncio.sleep(1) + case "": + pass From bfe172b03769e860cabc83b5a30d0931a3105012 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:33:00 +0200 Subject: [PATCH 37/56] Move to one Private Key and Spawn them on Startup --- .../frontend/states/chat_state.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index 3478f77c..ef73a48d 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -41,16 +41,16 @@ class ChatState(rx.State): signing_key: str = rx.LocalStorage("", name="signing_key", sync=True) verify_keys_storage: str = rx.LocalStorage("{}", name="verify_keys_storage", sync=True) # Own Private Keys and Others Public Keys for Private Chats - private_keys_storage: str = rx.LocalStorage("{}", name="private_keys_storage", sync=True) + private_key: str = rx.LocalStorage("", name="private_key", sync=True) public_keys_storage: str = rx.LocalStorage("{}", name="public_keys_storage", sync=True) # Verify Keys Storage Helpers - def get_key_storage(self, storage_name: Literal["verify_keys", "private_keys", "public_keys"]) -> dict[str, str]: + def get_key_storage(self, storage_name: Literal["verify_keys", "public_keys"]) -> dict[str, str]: """ Get the key storage for the specified storage name. Args: - storage_name (Literal["verify_keys", "private_keys", "public_keys"]): The name of the storage to retrieve. + storage_name (Literal["verify_keys", "public_keys"]): The name of the storage to retrieve. Returns: dict[str, str]: A dictionary containing the keys and their corresponding values. @@ -58,26 +58,24 @@ def get_key_storage(self, storage_name: Literal["verify_keys", "private_keys", " storage = self.__getattribute__(f"{storage_name}_storage") return cast("dict[str, str]", json.loads(storage)) - def dump_key_storage( - self, storage_name: Literal["verify_keys", "private_keys", "public_keys"], value: dict[str, str] - ) -> None: + def dump_key_storage(self, storage_name: Literal["verify_keys", "public_keys"], value: dict[str, str]) -> None: """ Dump the key storage to the specified storage name. Args: - storage_name (Literal["verify_keys", "private_keys", "public_keys"]): The name of the storage to dump to. + storage_name (Literal["verify_keys", "public_keys"]): The name of the storage to dump to. value (dict[str, str]): The dictionary containing the userIDs and their Keys. """ self.__setattr__(f"{storage_name}_storage", json.dumps(value)) def add_key_storage( - self, storage_name: Literal["verify_keys", "private_keys", "public_keys"], user_id: str, verify_key: str + self, storage_name: Literal["verify_keys", "public_keys"], user_id: str, verify_key: str ) -> None: """ Add a userID and its corresponding key to the specified storage. Args: - storage_name (Literal["verify_keys", "private_keys", "public_keys"]): The name of the storage to add to. + storage_name (Literal["verify_keys", "public_keys"]): The name of the storage to add to. user_id (str): The user ID to add. verify_key (str): The key to associate with the user ID. """ @@ -221,3 +219,8 @@ async def startup_event(self) -> AsyncGenerator[None, None]: if not self.signing_key or self.user_id not in self.get_key_storage("verify_keys"): self.signing_key, verify_key = Cryptographer.generate_signing_key_pair() self.add_key_storage("verify_keys", self.user_id, verify_key) + + # Generate new Private Key Pair if not set + if not self.private_key or self.user_id not in self.get_key_storage("public_keys"): + self.private_key, public_key = Cryptographer.generate_encryption_key_pair() + self.add_key_storage("public_keys", self.user_id, public_key) From 2e7e04338edeaa86a2d22f0974e4bbaee0fb0dc3 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:04:20 +0200 Subject: [PATCH 38/56] Implement Tos Accept Screen Current Tos: - https://freeocr.ai/terms-of-service" - https://aihorde.net/terms - https://freeimghost.net/page/tos --- .../frontend/components/tos_accept_form.py | 24 +++++++++++++++++++ src/witty_wisterias/frontend/frontend.py | 15 ++++++++---- .../frontend/states/chat_state.py | 8 +++++++ 3 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 src/witty_wisterias/frontend/components/tos_accept_form.py diff --git a/src/witty_wisterias/frontend/components/tos_accept_form.py b/src/witty_wisterias/frontend/components/tos_accept_form.py new file mode 100644 index 00000000..801c3b06 --- /dev/null +++ b/src/witty_wisterias/frontend/components/tos_accept_form.py @@ -0,0 +1,24 @@ +import reflex as rx + +from frontend.states.chat_state import ChatState + + +def tos_accept_form() -> rx.Component: + """Terms of Service Accept Form""" + return rx.form( + rx.vstack( + rx.text("You hereby accept the Terms of Service of:"), + rx.hstack( + rx.link("freeocr.ai", href="https://freeocr.ai/terms-of-service"), + rx.link("aihorde.net", href="https://aihorde.net/terms"), + rx.link("freeimghost.net", href="https://freeimghost.net/page/tos"), + align="center", + justify="center", + ), + rx.button("Accept", type="submit"), + align="center", + justify="center", + ), + on_submit=lambda _: ChatState.accept_tos(), + class_name="p-4 bg-gray-100 rounded-lg shadow-md w-screen h-screen flex items-center justify-center", + ) diff --git a/src/witty_wisterias/frontend/frontend.py b/src/witty_wisterias/frontend/frontend.py index ead0fd31..33f4f9bb 100644 --- a/src/witty_wisterias/frontend/frontend.py +++ b/src/witty_wisterias/frontend/frontend.py @@ -2,17 +2,22 @@ from frontend.components.chatapp import chat_app from frontend.components.sidebar import chat_sidebar +from frontend.components.tos_accept_form import tos_accept_form from frontend.states.chat_state import ChatState @rx.page(on_load=ChatState.startup_event) def index() -> rx.Component: """The main page of the chat application, which includes the sidebar and chat app components.""" - return rx.hstack( - chat_sidebar(), - chat_app(), - size="2", - class_name="overflow-hidden h-screen w-full", + return rx.cond( + ChatState.tos_accepted != "True", + tos_accept_form(), + rx.hstack( + chat_sidebar(), + chat_app(), + size="2", + class_name="overflow-hidden h-screen w-full", + ), ) diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index ef73a48d..1c9cb087 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -31,6 +31,8 @@ class Message(TypedDict): class ChatState(rx.State): """The Chat app state, used to handle Messages.""" + # Tos Accepted (Note: We need to use a string here because LocalStorage does not support booleans) + tos_accepted: str = rx.LocalStorage("False", name="tos_accepted", sync=True) # List of Messages messages: list[Message] = rx.field(default_factory=list) # Own User Data @@ -83,6 +85,12 @@ def add_key_storage( current_keys[user_id] = verify_key self.dump_key_storage(storage_name, current_keys) + @rx.event + def accept_tos(self) -> Generator[None, None]: + """Reflex Event when the Terms of Service are accepted.""" + self.tos_accepted = "True" + yield + @rx.event def send_text(self, form_data: dict[str, str]) -> Generator[None, None]: """ From a4d313e3686c46f8677faa8d7730e4731d897a8e Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:07:42 +0200 Subject: [PATCH 39/56] Update Sidebar User Info --- src/witty_wisterias/frontend/components/sidebar.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/witty_wisterias/frontend/components/sidebar.py b/src/witty_wisterias/frontend/components/sidebar.py index ad3354b9..01dc0708 100644 --- a/src/witty_wisterias/frontend/components/sidebar.py +++ b/src/witty_wisterias/frontend/components/sidebar.py @@ -1,5 +1,6 @@ import reflex as rx +from frontend.states.chat_state import ChatState from frontend.states.progress_state import ProgressState @@ -44,10 +45,12 @@ def chat_sidebar() -> rx.Component: rx.heading(ProgressState.progress, size="2", class_name="text-gray-500"), rx.divider(), rx.hstack( - rx.avatar(fallback="ID", radius="large", size="3"), + rx.avatar( + src=ChatState.user_profile_image, fallback=ChatState.user_id[:2], radius="large", size="3" + ), rx.vstack( - rx.text("User Name", size="3"), - rx.text("UserID", size="1", class_name="text-gray-500"), + rx.text(ChatState.user_name | ChatState.user_id, size="3"), + rx.text(ChatState.user_id, size="1", class_name="text-gray-500"), spacing="0", ), class_name="mt-1", From 507ceacff6bf7f654cbb2f86c907341b18dc403b Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Thu, 14 Aug 2025 17:50:38 +0200 Subject: [PATCH 40/56] Create User Input Handlers - OCR using https://freeocr.ai/ - Text2Image using https://pollinations.ai/ --- .../backend/user_input_handler.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/witty_wisterias/backend/user_input_handler.py diff --git a/src/witty_wisterias/backend/user_input_handler.py b/src/witty_wisterias/backend/user_input_handler.py new file mode 100644 index 00000000..8057aad4 --- /dev/null +++ b/src/witty_wisterias/backend/user_input_handler.py @@ -0,0 +1,77 @@ +import base64 + +import httpx +import regex as re +from bs4 import BeautifulSoup + +from .exceptions import InvalidResponseError + +# Global HTTP Session for the User Input Handler +HTTP_SESSION = httpx.Client(timeout=30) + + +class UserInputHandler: + """ + UserInputHandler class to convert images to text and text to images, to help the Theme "Wrong tool for the job". + Which also gets Implemented in the Frontend User Input and converted here. + """ + + @staticmethod + def image_to_text(image_base64: str) -> str: + """ + Converts a base64 encoded image to text using https://freeocr.ai/. + + Args: + image_base64 (str): A base64-encoded string representing the image. + + Returns: + str: The text extracted from the image. + """ + # Getting some Cookies etc. + page_resp = HTTP_SESSION.get("https://freeocr.ai/") + # Getting All JS Scripts from the Page + soup = BeautifulSoup(page_resp.text, "html.parser") + js_script_links = [script.get("src") for script in soup.find_all("script") if script.get("src")] + # Getting Page Script Content + page_js_script: str | None = next((src for src in js_script_links if "page-" in src), None) + if not page_js_script: + raise InvalidResponseError("Could not find the page script in the response.") + page_script_content = HTTP_SESSION.get("https://freeocr.ai" + page_js_script).text + # Getting the Next-Action by searching for a 42 character long hex string + next_action_search = re.search(r"[a-f0-9]{42}", page_script_content) + if not next_action_search: + raise InvalidResponseError("Could not find Next-Action in the response.") + next_action = next_action_search.group(0) + + # Posting to the OCR service + resp = HTTP_SESSION.post( + "https://freeocr.ai/", + json=["data:image/jpeg;base64," + image_base64], + headers={ + "Next-Action": next_action, + "Next-Router-State-Tree": "%5B%22%22%2C%7B%22children%22%3A%5B%5B%22locale%22%2C%22de%22%2" + "C%22d%22%5D%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C" + "%22%2Fde%22%2C%22refresh%22%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%5D", + }, + ) + # Removing Content Headers to extract the text + extracted_text: str = resp.text.splitlines()[1][3:-1] + return extracted_text + + @staticmethod + def text_to_image(text: str) -> str: + """ + Converts text to an image link using https://pollinations.ai/ + + Args: + text (str): The text to convert to an image. + + Returns: + str: The base64 encoded generated image. + """ + # Lowest Quality for best Speed (and low Database Usage) + generation_url = f"https://image.pollinations.ai/prompt/{text}?width=256&height=256&quality=low" + # Getting the Generated Image Content + generated_image = HTTP_SESSION.get(generation_url).content + # Encode the image content to base64 + return base64.b64encode(generated_image).decode("utf-8") From b7adb2dfa92ecda138620dbf068633baa2619cec Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Fri, 15 Aug 2025 13:30:54 +0200 Subject: [PATCH 41/56] Implement Private Messages --- src/witty_wisterias/backend/backend.py | 126 +++++++- src/witty_wisterias/backend/message_format.py | 13 +- .../frontend/components/chat_bubble.py | 20 +- .../frontend/components/chatapp.py | 65 +++- .../frontend/components/create_chat.py | 56 ++++ .../frontend/components/image_button.py | 70 ++-- .../frontend/components/sidebar.py | 37 ++- .../frontend/components/text_button.py | 70 ++-- .../frontend/components/tos_accept_form.py | 2 +- .../frontend/states/chat_state.py | 301 +++++++++++++++++- 10 files changed, 660 insertions(+), 100 deletions(-) create mode 100644 src/witty_wisterias/frontend/components/create_chat.py diff --git a/src/witty_wisterias/backend/backend.py b/src/witty_wisterias/backend/backend.py index 95b7c8b8..3763b34c 100644 --- a/src/witty_wisterias/backend/backend.py +++ b/src/witty_wisterias/backend/backend.py @@ -6,7 +6,7 @@ from .cryptographer import Cryptographer from .database import Database from .exceptions import InvalidDataError -from .message_format import MessageFormat +from .message_format import EventType, MessageFormat @dataclass @@ -89,6 +89,40 @@ def encode(upload_stack: UploadStack) -> str: # Encode to base64 for safe transmission return base64.b64encode(compressed_stack).decode("utf-8") + @staticmethod + def push_public_keys(user_id: str, verify_key: str, public_key: str) -> None: + """ + Push public keys to the Upload Stack. + + Args: + user_id (str): The ID of the user. + verify_key (str): The verify key of the user. + public_key (str): The public key of the user. + """ + queried_data = Backend.decode(Database.query_data()) + + # Append the verify_key to the Upload Stack if not already present + if user_id not in queried_data.verify_keys_stack: + queried_data.verify_keys_stack[user_id] = verify_key + + # Append the public_key to the Upload Stack if not already present + if user_id not in queried_data.public_keys_stack: + queried_data.public_keys_stack[user_id] = public_key + + Database.upload_data(Backend.encode(queried_data)) + + @staticmethod + def read_public_keys() -> tuple[dict[str, str], dict[str, str]]: + """ + Read verify and public keys from the Upload Stack. + + Returns: + dict[str, str]: A dictionary containing user IDs as keys and their verify keys as values. + dict[str, str]: A dictionary containing user IDs as keys and their public keys as values. + """ + queried_data = Backend.decode(Database.query_data()) + return queried_data.verify_keys_stack, queried_data.public_keys_stack + @staticmethod def send_public_message(message: MessageFormat) -> None: """ @@ -97,13 +131,15 @@ def send_public_message(message: MessageFormat) -> None: Args: message (MessageFormat): The message to be sent, containing senderID, event type, content, and signing key. """ - if not (message.sender_id and message.event_type and message.content and message.signing_key): + if not ( + message.sender_id and message.event_type and message.content and message.timestamp and message.signing_key + ): raise InvalidDataError("MessageFormat is not complete") queried_data = Backend.decode(Database.query_data()) # Append the verify_key to the Upload Stack if not already present - if message.verify_key not in queried_data.verify_keys_stack: + if message.sender_id not in queried_data.verify_keys_stack: queried_data.verify_keys_stack[message.sender_id] = message.verify_key # Sign the message using the Signing Key @@ -119,6 +155,48 @@ def send_public_message(message: MessageFormat) -> None: Database.upload_data(Backend.encode(queried_data)) + @staticmethod + def send_private_message(message: MessageFormat) -> None: + """ + Send a private message to the Database. + + Args: + message (MessageFormat): The message to be sent, containing senderID, event type, content, and signing key. + """ + if not ( + message.sender_id + and message.receiver_id + and message.event_type + and message.content + and message.timestamp + and message.own_public_key + and message.receiver_public_key + and message.private_key + ): + raise InvalidDataError("MessageFormat is not complete") + + queried_data = Backend.decode(Database.query_data()) + + # Append own Public Key to the Upload Stack if not already present + if message.sender_id not in queried_data.public_keys_stack: + queried_data.public_keys_stack[message.sender_id] = message.own_public_key + + # Encrypt the message content using the receiver's public key + encrypted_message = Cryptographer.encrypt_message( + message.content, message.private_key, message.receiver_public_key + ) + private_message = MessageFormat( + sender_id=message.sender_id, + receiver_id=message.receiver_id, + event_type=message.event_type, + content=encrypted_message, + timestamp=message.timestamp, + ) + + queried_data.message_stack.append(private_message) + + Database.upload_data(Backend.encode(queried_data)) + @staticmethod def read_public_messages() -> list[MessageFormat]: """ @@ -129,8 +207,13 @@ def read_public_messages() -> list[MessageFormat]: verified_messaged: list[MessageFormat] = [] for message in decoded_data.message_stack: + if isinstance(message, str): + continue + # Checking if the message is a public message + if message.event_type not in (EventType.PUBLIC_TEXT, EventType.PUBLIC_IMAGE): + continue # Signature Verification - if not isinstance(message, str) and message.sender_id in decoded_data.verify_keys_stack: + if message.sender_id in decoded_data.verify_keys_stack: verify_key = decoded_data.verify_keys_stack[message.sender_id] try: # Verify the message content using the verify key @@ -140,3 +223,38 @@ def read_public_messages() -> list[MessageFormat]: pass return verified_messaged + + @staticmethod + def read_private_messages(user_id: str, private_key: str) -> list[MessageFormat]: + """ + Read private messages for a specific receiver. + This method should be overridden by the backend. + + Args: + user_id (str): The ID of own user which tries to read private messages. + private_key (str): The private key of the receiver used for decrypting messages. + + Returns: + list[MessageFormat]: A list of decrypted private messages for the specified receiver. + """ + decoded_data = Backend.decode(Database.query_data()) + + decrypted_messages: list[MessageFormat] = [] + for message in decoded_data.message_stack: + if isinstance(message, str): + continue + # Checking if the message is a private message + if message.event_type not in (EventType.PRIVATE_TEXT, EventType.PRIVATE_IMAGE): + continue + # Message Decryption check + if message.receiver_id == user_id and message.sender_id in decoded_data.public_keys_stack: + try: + sender_public_key = decoded_data.public_keys_stack[message.sender_id] + # Decrypt the message content using the receiver's private key + decrypted_content = Cryptographer.decrypt_message(message.content, private_key, sender_public_key) + message.content = decrypted_content + decrypted_messages.append(message) + except ValueError: + pass + + return decrypted_messages diff --git a/src/witty_wisterias/backend/message_format.py b/src/witty_wisterias/backend/message_format.py index 6e8bee63..431ea012 100644 --- a/src/witty_wisterias/backend/message_format.py +++ b/src/witty_wisterias/backend/message_format.py @@ -17,7 +17,7 @@ class EventType(Enum): SET_PROFILEPICTURE = auto() -class MessageJson(TypedDict): +class MessageFormatJson(TypedDict): """ Defines the structure of the JSON representation of a message. This is used for serialization and deserialization of messages. @@ -61,11 +61,12 @@ class MessageFormat: receiver_id: str = field(default="None") signing_key: str = field(default="") verify_key: str = field(default="") + own_public_key: str = field(default="") + receiver_public_key: str = field(default="") private_key: str = field(default="") - public_key: str = field(default="") extra_event_info: ExtraEventInfo = field(default_factory=ExtraEventInfo) - def to_dict(self) -> MessageJson: + def to_dict(self) -> MessageFormatJson: """Convert the message into a Python dictionary.""" return { "header": { @@ -74,8 +75,9 @@ def to_dict(self) -> MessageJson: "event_type": self.event_type.name, "signing_key": self.signing_key, "verify_key": self.verify_key, - "public_key": self.public_key, + "own_public_key": self.own_public_key, "private_key": self.private_key, + "receiver_public_key": self.receiver_public_key, "timestamp": self.timestamp, }, "body": {"content": self.content, "extra_event_info": self.extra_event_info.to_dict()}, @@ -95,7 +97,8 @@ def from_json(data: str) -> "MessageFormat": event_type=EventType[obj["header"]["event_type"]], signing_key=obj["header"].get("signing_key"), verify_key=obj["header"].get("verify_key"), - public_key=obj["header"].get("public_key"), + own_public_key=obj["header"].get("own_public_key"), + receiver_public_key=obj["header"].get("receiver_public_key"), private_key=obj["header"].get("private_key"), timestamp=obj["header"]["timestamp"], content=obj["body"]["content"], diff --git a/src/witty_wisterias/frontend/components/chat_bubble.py b/src/witty_wisterias/frontend/components/chat_bubble.py index 92c9ae6c..9a9a82e0 100644 --- a/src/witty_wisterias/frontend/components/chat_bubble.py +++ b/src/witty_wisterias/frontend/components/chat_bubble.py @@ -1,10 +1,13 @@ import reflex as rx from PIL import Image +from frontend.components.create_chat import create_chat_component + def chat_bubble_component( message: str | Image.Image, user_name: str, + user_id: str, user_profile_image: str | None = None, own_message: bool = False, is_image_message: bool = False, @@ -14,6 +17,7 @@ def chat_bubble_component( Args: message (str): The content of the message, either text or base64-encoded image. user_name (str): The name of the user who sent the message. + user_id (str): The UserID of the user who sent the message. user_profile_image (str): The URL of the user's profile image. own_message (bool): Whether the message is sent by the current user. is_image_message (bool): Whether the message is an image. If True, `message` should be a Pillow Image. @@ -28,7 +32,7 @@ def chat_bubble_component( size="3", ) message_content = rx.vstack( - rx.text(user_name, class_name="font-semibold"), + rx.text(user_name, class_name="font-semibold text-gray-600"), rx.cond( is_image_message, rx.image(src=message, alt="Image message", max_width="500px", max_height="500px"), @@ -38,18 +42,14 @@ def chat_bubble_component( spacing="0", ) - return rx.hstack( + chat_bubble = rx.hstack( rx.cond( own_message, - [ - message_content, - avatar, - ], - [ - avatar, - message_content, - ], + [message_content, avatar], + [avatar, message_content], ), class_name="items-start space-x-2 bg-gray-100 p-4 rounded-lg shadow-sm", style={"alignSelf": rx.cond(own_message, "flex-end", "flex-start")}, ) + # Allow creating a private chat by clicking on others user's chat message + return rx.cond(own_message, chat_bubble, create_chat_component(chat_bubble, user_id)) diff --git a/src/witty_wisterias/frontend/components/chatapp.py b/src/witty_wisterias/frontend/components/chatapp.py index 8a22956f..4f839137 100644 --- a/src/witty_wisterias/frontend/components/chatapp.py +++ b/src/witty_wisterias/frontend/components/chatapp.py @@ -3,25 +3,70 @@ from frontend.components.chat_bubble import chat_bubble_component from frontend.components.image_button import send_image_component from frontend.components.text_button import send_text_component -from frontend.states.chat_state import ChatState +from frontend.states.chat_state import ChatState, Message + + +def chat_specific_messages(message: Message) -> rx.Component: + """ + Returns the correct chat bubble if the message is for the selected chat. + + Args: + message (Message): The Message object, which is to be determined if it fits the selected chat. + + Returns: + rx.Component: A component representing the chat bubble for the message, if it fits. + """ + user_id = message.get("user_id") + receiver_id = message.get("receiver_id") + selected_chat = ChatState.selected_chat + + return rx.cond( + # Public Chat Messages + (selected_chat == "Public") & (~receiver_id), + chat_bubble_component( + message["message"], + rx.cond(message["user_name"], message["user_name"], user_id), + user_id, + message["user_profile_image"], + message["own_message"], + message["is_image_message"], + ), + rx.cond( + # Private Chat Messages + (selected_chat != "Public") & receiver_id & ((selected_chat == receiver_id) | (selected_chat == user_id)), + chat_bubble_component( + message["message"], + rx.cond(message["user_name"], message["user_name"], user_id), + user_id, + message["user_profile_image"], + message["own_message"], + message["is_image_message"], + ), + # Fallback + rx.fragment(), + ), + ) def chat_app() -> rx.Component: """Main chat application component.""" return rx.vstack( + rx.heading( + rx.cond( + ChatState.selected_chat == "Public", "Public Chat", f"Private Chat with {ChatState.selected_chat}" + ), + spacing=0, + size="6", + align="center", + class_name="text-gray-700 mt-4 w-full", + ), + rx.divider(), rx.auto_scroll( rx.foreach( ChatState.messages, - lambda message: chat_bubble_component( - message["message"], - # Use UserID as fallback for Username - rx.cond(message["user_name"], message["user_name"], message["user_id"]), - message["user_profile_image"], - message["own_message"], - message["is_image_message"], - ), + lambda message: chat_specific_messages(message), ), - class_name="flex flex-col gap-4 pb-6 pt-6 h-full w-full mt-5 bg-gray-50 p-5 rounded-xl shadow-sm", + class_name="flex flex-col gap-4 pb-6 pt-3 h-full w-full bg-gray-50 p-5 rounded-xl shadow-sm", ), rx.divider(), rx.hstack( diff --git a/src/witty_wisterias/frontend/components/create_chat.py b/src/witty_wisterias/frontend/components/create_chat.py new file mode 100644 index 00000000..9143d0bb --- /dev/null +++ b/src/witty_wisterias/frontend/components/create_chat.py @@ -0,0 +1,56 @@ +import reflex as rx + +from frontend.states.chat_state import ChatState + + +def create_chat_component(create_chat_button: rx.Component, user_id: str | None = None) -> rx.Component: + """The create-new-chat button, which spawns a dialog to create a new private chat.""" + return rx.dialog.root( + rx.dialog.trigger(create_chat_button), + rx.dialog.content( + rx.dialog.title("Create new Private Chat"), + rx.dialog.description( + "Create a new Private Chat with a user by entering their User ID.", + size="2", + margin_bottom="16px", + ), + rx.form( + rx.vstack( + rx.input( + placeholder="Enter Receiver UserID", + default_value=user_id, + name="receiver_id", + required=True, + variant="surface", + class_name="w-full", + ), + rx.text_area( + placeholder="Write your first message...", + size="3", + rows="5", + name="message", + required=True, + variant="surface", + class_name="w-full", + ), + rx.hstack( + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ), + ), + rx.dialog.close( + rx.button("Send", type="submit"), + ), + ), + spacing="3", + margin_top="16px", + justify="end", + ), + on_submit=ChatState.send_private_text, + reset_on_submit=False, + ), + ), + ) diff --git a/src/witty_wisterias/frontend/components/image_button.py b/src/witty_wisterias/frontend/components/image_button.py index 083d19c3..ffa192cf 100644 --- a/src/witty_wisterias/frontend/components/image_button.py +++ b/src/witty_wisterias/frontend/components/image_button.py @@ -3,6 +3,41 @@ from frontend.states.chat_state import ChatState +def image_form() -> rx.Component: + """ + Form for sending an image message. + + Returns: + rx.Component: The Image form component. + """ + return rx.vstack( + rx.text_area( + placeholder="Describe it here...", + size="3", + rows="5", + name="message", + required=True, + variant="surface", + class_name="w-full", + ), + rx.hstack( + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ), + ), + rx.dialog.close( + rx.button("Send", type="submit"), + ), + ), + spacing="3", + margin_top="16px", + justify="end", + ) + + def send_image_component() -> rx.Component: """The dialog (and button) for sending an image""" return rx.dialog.root( @@ -21,33 +56,16 @@ def send_image_component() -> rx.Component: size="2", margin_bottom="16px", ), - rx.form( - rx.flex( - rx.text_area( - placeholder="Describe it here...", - size="3", - rows="5", - name="message", - required=True, - variant="surface", - class_name="w-full", - ), - rx.dialog.close( - rx.button( - "Cancel", - variant="soft", - color_scheme="gray", - ), - ), - rx.dialog.close( - rx.button("Send", type="submit"), - ), - spacing="3", - margin_top="16px", - justify="end", + rx.cond( + ChatState.selected_chat == "Public", + rx.form( + image_form(), + on_submit=ChatState.send_public_image, + ), + rx.form( + image_form(), + on_submit=ChatState.send_private_image, ), - on_submit=ChatState.send_image, - reset_on_submit=False, ), ), ) diff --git a/src/witty_wisterias/frontend/components/sidebar.py b/src/witty_wisterias/frontend/components/sidebar.py index 01dc0708..ca0ae030 100644 --- a/src/witty_wisterias/frontend/components/sidebar.py +++ b/src/witty_wisterias/frontend/components/sidebar.py @@ -1,5 +1,6 @@ import reflex as rx +from frontend.components.create_chat import create_chat_component from frontend.states.chat_state import ChatState from frontend.states.progress_state import ProgressState @@ -24,22 +25,32 @@ def chat_sidebar() -> rx.Component: variant="surface", size="3", class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", + on_click=ChatState.select_chat("Public"), ), rx.divider(), - rx.heading("Private Chats", size="2", class_name="text-gray-500"), - rx.button( - "Private Chat 1", - color_scheme="teal", - variant="surface", - size="3", - class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", + rx.hstack( + rx.heading("Private Chats", size="2", class_name="text-gray-500"), + create_chat_component( + rx.button( + rx.icon("circle-plus", size=16, class_name="text-gray-500"), + class_name="bg-white", + ) + ), + spacing="2", + align="center", + justify="between", + class_name="w-full mb-0", ), - rx.button( - "Private Chat 2", - color_scheme="teal", - variant="surface", - size="3", - class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", + rx.foreach( + ChatState.chat_partners, + lambda user_id: rx.button( + f"Private: {user_id}", + color_scheme="teal", + variant="surface", + size="3", + class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", + on_click=ChatState.select_chat(user_id), + ), ), rx.vstack( rx.heading(ProgressState.progress, size="2", class_name="text-gray-500"), diff --git a/src/witty_wisterias/frontend/components/text_button.py b/src/witty_wisterias/frontend/components/text_button.py index b04689c5..15498471 100644 --- a/src/witty_wisterias/frontend/components/text_button.py +++ b/src/witty_wisterias/frontend/components/text_button.py @@ -3,6 +3,41 @@ from frontend.states.chat_state import ChatState +def text_form() -> rx.Component: + """ + Form for sending a text message. + + Returns: + rx.Component: The Text form component. + """ + return rx.vstack( + rx.text_area( + placeholder="Write your text here...", + size="3", + rows="5", + name="message", + required=True, + variant="surface", + class_name="w-full", + ), + rx.hstack( + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ), + ), + rx.dialog.close( + rx.button("Send", type="submit"), + ), + ), + spacing="3", + margin_top="16px", + justify="end", + ) + + def send_text_component() -> rx.Component: """The dialog (and button) for sending texts""" # TODO: This should be replaced with the Webcam handler, text will do for now @@ -22,33 +57,16 @@ def send_text_component() -> rx.Component: size="2", margin_bottom="16px", ), - rx.form( - rx.flex( - rx.text_area( - placeholder="Write your text here...", - size="3", - rows="5", - name="message", - required=True, - variant="surface", - class_name="w-full", - ), - rx.dialog.close( - rx.button( - "Cancel", - variant="soft", - color_scheme="gray", - ), - ), - rx.dialog.close( - rx.button("Send", type="submit"), - ), - spacing="3", - margin_top="16px", - justify="end", + rx.cond( + ChatState.selected_chat == "Public", + rx.form( + text_form(), + on_submit=ChatState.send_public_text, + ), + rx.form( + text_form(), + on_submit=ChatState.send_private_text, ), - on_submit=ChatState.send_text, - reset_on_submit=False, ), ), ) diff --git a/src/witty_wisterias/frontend/components/tos_accept_form.py b/src/witty_wisterias/frontend/components/tos_accept_form.py index 801c3b06..96621e89 100644 --- a/src/witty_wisterias/frontend/components/tos_accept_form.py +++ b/src/witty_wisterias/frontend/components/tos_accept_form.py @@ -10,7 +10,7 @@ def tos_accept_form() -> rx.Component: rx.text("You hereby accept the Terms of Service of:"), rx.hstack( rx.link("freeocr.ai", href="https://freeocr.ai/terms-of-service"), - rx.link("aihorde.net", href="https://aihorde.net/terms"), + rx.link("pollinations.ai", href="https://pollinations.ai/terms"), rx.link("freeimghost.net", href="https://freeimghost.net/page/tos"), align="center", justify="center", diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index 1c9cb087..e11db0a3 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -1,8 +1,10 @@ import asyncio +import base64 import io import json import threading from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass from datetime import UTC, datetime from typing import Literal, TypedDict, cast @@ -16,17 +18,112 @@ from frontend.states.progress_state import ProgressState -class Message(TypedDict): +class MessageJson(TypedDict): + """ + Defines the structure of the JSON representation of a message. + This is used for serialization and deserialization of messages. + """ + + message: str + user_id: str + receiver_id: str | None + user_name: str + user_profile_image: str | None + own_message: bool + is_image_message: bool + timestamp: float + + +@dataclass +class Message: """A message in the chat application.""" message: str | Image.Image user_id: str + receiver_id: str | None user_name: str user_profile_image: str | None own_message: bool is_image_message: bool timestamp: float + @staticmethod + def from_message_format(message_format: MessageFormat) -> "Message": + """ + Convert a MessageFormat object to a Message object. + + Args: + message_format (MessageFormat): The MessageFormat object to convert. + + Returns: + Message: A Message object created from the MessageFormat. + """ + return Message( + message=message_format.content, + user_id=message_format.sender_id, + receiver_id=message_format.receiver_id if message_format.receiver_id != "None" else None, + user_name=message_format.extra_event_info.user_name or message_format.sender_id, + user_profile_image=message_format.extra_event_info.user_image, + own_message=str(ChatState.user_id) == message_format.sender_id, + is_image_message=message_format.event_type in (EventType.PUBLIC_IMAGE, EventType.PRIVATE_IMAGE), + timestamp=message_format.timestamp, + ) + + @staticmethod + def from_dict(data: MessageJson) -> "Message": + """ + Convert a dictionary to a Message object. + + Args: + data (dict[str, str]): The dictionary containing message data. + + Returns: + Message: A Message object created from the dictionary. + """ + if data.get("is_image_message", False): + # Decode the base64 image data to an Image object + image_data = base64.b64decode(data["message"]) + message_content = Image.open(io.BytesIO(image_data)) + message_content = message_content.convert("RGB") + else: + message_content = data["message"] + return Message( + message=message_content, + user_id=data["user_id"], + receiver_id=data.get("receiver_id"), + user_name=data["user_name"], + user_profile_image=data.get("user_profile_image"), + own_message=data.get("own_message", False), + is_image_message=data.get("is_image_message", False), + timestamp=float(data["timestamp"]), + ) + + def to_dict(self) -> MessageJson: + """ + Convert the message into a Python dictionary. + + Returns: + MessageJson: A dictionary representation of the message. + """ + if isinstance(self.message, Image.Image): + # Convert the image to bytes and encode it in base64 (JPEG to save limited LocalStorage space) + buffered = io.BytesIO() + self.message.save(buffered, format="JPEG") + message_data = base64.b64encode(buffered.getvalue()).decode("utf-8") + else: + message_data = self.message + + return { + "message": message_data, + "user_id": self.user_id, + "receiver_id": self.receiver_id, + "user_name": self.user_name, + "user_profile_image": self.user_profile_image, + "own_message": self.own_message, + "is_image_message": self.is_image_message, + "timestamp": self.timestamp, + } + class ChatState(rx.State): """The Chat app state, used to handle Messages.""" @@ -35,6 +132,13 @@ class ChatState(rx.State): tos_accepted: str = rx.LocalStorage("False", name="tos_accepted", sync=True) # List of Messages messages: list[Message] = rx.field(default_factory=list) + # We need to store our own private messages in LocalStorage, as we cannot decrypt them from the Database + own_private_messages: str = rx.LocalStorage("[]", name="private_messages", sync=True) + # Chat Partners + chat_partners: list[str] = rx.field(default_factory=list) + # Current Selected Chat + selected_chat: str = rx.LocalStorage("Public", name="selected_chat", sync=True) + # Own User Data user_id: str = rx.LocalStorage("", name="user_id", sync=True) user_name: str = rx.LocalStorage("", name="user_name", sync=True) @@ -85,6 +189,18 @@ def add_key_storage( current_keys[user_id] = verify_key self.dump_key_storage(storage_name, current_keys) + def register_chat_partner(self, user_id: str) -> None: + """ + Register a new chat partner by adding their user ID to the chat partners list. + + Args: + user_id (str): The user ID of the chat partner to register. + """ + if user_id not in self.chat_partners: + self.chat_partners.append(user_id) + # Sort to find the chat partner in the list more easily + self.chat_partners.sort() + @rx.event def accept_tos(self) -> Generator[None, None]: """Reflex Event when the Terms of Service are accepted.""" @@ -92,7 +208,18 @@ def accept_tos(self) -> Generator[None, None]: yield @rx.event - def send_text(self, form_data: dict[str, str]) -> Generator[None, None]: + def select_chat(self, chat_name: str) -> Generator[None, None]: + """ + Reflex Event when a chat is selected. + + Args: + chat_name (str): The name of the chat to select. + """ + self.selected_chat = chat_name + yield + + @rx.event + def send_public_text(self, form_data: dict[str, str]) -> Generator[None, None]: """ Reflex Event when a text message is sent. @@ -111,6 +238,7 @@ def send_text(self, form_data: dict[str, str]) -> Generator[None, None]: message=message, user_id=self.user_id, user_name=self.user_name, + receiver_id=None, user_profile_image=self.user_profile_image, own_message=True, is_image_message=False, @@ -136,7 +264,7 @@ def send_text(self, form_data: dict[str, str]) -> Generator[None, None]: threading.Thread(target=Backend.send_public_message, args=(message_format,), daemon=True).start() @rx.event - def send_image(self, form_data: dict[str, str]) -> Generator[None, None]: + def send_public_image(self, form_data: dict[str, str]) -> Generator[None, None]: """ Reflex Event when an image message is sent. @@ -159,6 +287,7 @@ def send_image(self, form_data: dict[str, str]) -> Generator[None, None]: message=img, user_id=self.user_id, user_name=self.user_name, + receiver_id=None, user_profile_image=self.user_profile_image, own_message=True, is_image_message=True, @@ -183,15 +312,148 @@ def send_image(self, form_data: dict[str, str]) -> Generator[None, None]: # If you find something better, please update this. threading.Thread(target=Backend.send_public_message, args=(message_format,), daemon=True).start() + @rx.event + def send_private_text(self, form_data: dict[str, str]) -> Generator[None, None]: + """ + Reflex Event when a private text message is sent. + + Args: + form_data (dict[str, str]): The form data containing the message in the `message` field. + """ + message = form_data.get("message", "").strip() + receiver_id = form_data.get("receiver_id", "").strip() or self.selected_chat + if message and receiver_id: + if receiver_id not in self.get_key_storage("public_keys"): + # Cant message someone who is not registered + raise ValueError("Recipients Public Key is not registered.") + + self.register_chat_partner(receiver_id) + self.selected_chat = receiver_id + yield + + # Sending Placebo Progress Bar + yield ProgressState.private_message_progress + + message_timestamp = datetime.now(UTC).timestamp() + # Appending new own message + chat_message = Message( + message=message, + user_id=self.user_id, + user_name=self.user_name, + receiver_id=receiver_id, + user_profile_image=self.user_profile_image, + own_message=True, + is_image_message=False, + timestamp=message_timestamp, + ) + + self.messages.append(chat_message) + # Also append to own private messages, as we cannot decrypt them from the Database + own_private_messages_json = json.loads(self.own_private_messages) + own_private_messages_json.append(chat_message.to_dict()) + # Encode back to String JSON + self.own_private_messages = json.dumps(own_private_messages_json) + yield + + # Posting message to backend + message_format = MessageFormat( + sender_id=self.user_id, + receiver_id=receiver_id, + event_type=EventType.PRIVATE_TEXT, + content=message, + timestamp=message_timestamp, + own_public_key=self.get_key_storage("public_keys")[self.user_id], + receiver_public_key=self.get_key_storage("public_keys")[receiver_id], + private_key=self.private_key, + ) + # Note: We need to use threading here, even if it looks odd. This is because the + # Backend.send_private_message method blocks the UI thread. So we need to run it in a separate thread. + # Something like asyncio.to_thread or similar doesn't work here, not 100% sure why, my best guess is + # that it does not separate from the UI thread properly. So threading is the next best option. + # If you find something better, please update this. + threading.Thread(target=Backend.send_private_message, args=(message_format,), daemon=True).start() + + @rx.event + def send_private_image(self, form_data: dict[str, str]) -> Generator[None, None]: + """ + Reflex Event when a private image message is sent. + + Args: + form_data (dict[str, str]): The form data containing the image URL in the `message` field. + """ + message = form_data.get("message", "").strip() + receiver_id = form_data.get("receiver_id", "").strip() or self.selected_chat + if message and receiver_id: + if receiver_id not in self.get_key_storage("public_keys"): + # Cant message someone who is not registered + raise ValueError("Recipients Public Key is not registered.") + + self.register_chat_partner(receiver_id) + self.selected_chat = receiver_id + yield + + # Temporary + response = requests.get(message, timeout=10) + img = Image.open(io.BytesIO(response.content)) + + # Sending Placebo Progress Bar + yield ProgressState.private_message_progress + + message_timestamp = datetime.now(UTC).timestamp() + # Appending new own message + chat_message = Message( + message=img, + user_id=self.user_id, + user_name=self.user_name, + receiver_id=receiver_id, + user_profile_image=self.user_profile_image, + own_message=True, + is_image_message=True, + timestamp=message_timestamp, + ) + self.messages.append(chat_message) + # Also append to own private messages, as we cannot decrypt them from the Database + own_private_messages_json = json.loads(self.own_private_messages) + own_private_messages_json.append(chat_message.to_dict()) + # Encode back to String JSON + self.own_private_messages = json.dumps(own_private_messages_json) + yield + + # Posting message to backend + message_format = MessageFormat( + sender_id=self.user_id, + receiver_id=receiver_id, + event_type=EventType.PRIVATE_IMAGE, + content=message, + timestamp=message_timestamp, + own_public_key=self.get_key_storage("public_keys")[self.user_id], + receiver_public_key=self.get_key_storage("public_keys")[receiver_id], + private_key=self.private_key, + ) + # Note: We need to use threading here, even if it looks odd. This is because the + # Backend.send_private_message method blocks the UI thread. So we need to run it in a separate thread. + # Something like asyncio.to_thread or similar doesn't work here, not 100% sure why, my best guess is + # that it does not separate from the UI thread properly. So threading is the next best option. + # If you find something better, please update this. + threading.Thread(target=Backend.send_private_message, args=(message_format,), daemon=True).start() + @rx.event(background=True) async def check_messages(self) -> None: """Reflex Background Check for new messages.""" while True: async with self: + # Read Verify and Public Keys from Backend + verify_keys, public_keys = Backend.read_public_keys() + for user_id, verify_key in verify_keys.items(): + self.add_key_storage("verify_keys", user_id, verify_key) + for user_id, public_key in public_keys.items(): + self.add_key_storage("public_keys", user_id, public_key) + + # Public Chat Messages for message in Backend.read_public_messages(): # Check if the message is already in the chat using timestamp message_exists = any( - msg["timestamp"] == message.timestamp and msg["user_id"] == message.sender_id + msg.timestamp == message.timestamp and msg.user_id == message.sender_id for msg in self.messages ) @@ -202,12 +464,36 @@ async def check_messages(self) -> None: message=message.content, user_id=message.sender_id, user_name=message.extra_event_info.user_name, + receiver_id=None, user_profile_image=message.extra_event_info.user_image, own_message=self.user_id == message.sender_id, - is_image_message=False, + is_image_message=message.event_type == EventType.PUBLIC_IMAGE, timestamp=message.timestamp, ) ) + # Private Chat Messages + backend_private_message_formats = Backend.read_private_messages(self.user_id, self.private_key) + backend_private_messages = [ + Message.from_message_format(message_format) for message_format in backend_private_message_formats + ] + own_private_messages_json = json.loads(self.own_private_messages) + own_private_messages = [Message.from_dict(message_data) for message_data in own_private_messages_json] + sorted_private_messages = sorted( + backend_private_messages + own_private_messages, + key=lambda msg: msg.timestamp, + ) + for message in sorted_private_messages: + # Add received chat partner to chat partners list + if message.user_id != self.user_id: + self.register_chat_partner(message.user_id) + # Check if the message is already in the chat using timestamp + message_exists = any( + msg.timestamp == message.timestamp and msg.user_id == message.user_id for msg in self.messages + ) + + # Check if message is not already in the chat + if not message_exists: + self.messages.append(message) # Wait for 5 seconds before checking for new messages again to avoid excessive load await asyncio.sleep(5) @@ -232,3 +518,8 @@ async def startup_event(self) -> AsyncGenerator[None, None]: if not self.private_key or self.user_id not in self.get_key_storage("public_keys"): self.private_key, public_key = Cryptographer.generate_encryption_key_pair() self.add_key_storage("public_keys", self.user_id, public_key) + + # Ensure the Public Keys are Uploaded + verify_key = self.get_key_storage("verify_keys")[self.user_id] + public_key = self.get_key_storage("public_keys")[self.user_id] + Backend.push_public_keys(self.user_id, verify_key, public_key) From 11019bb9c4eb49f50447bb2552ed33bfb51607e1 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Fri, 15 Aug 2025 14:46:02 +0200 Subject: [PATCH 42/56] Add User Information Editor This could be improved with a previous message updater, but its good for now and probably this CodeJam. --- src/witty_wisterias/backend/backend.py | 10 +++- src/witty_wisterias/backend/message_format.py | 4 +- .../frontend/components/chat_bubble.py | 2 +- .../frontend/components/sidebar.py | 23 +++++--- .../frontend/components/user_info.py | 57 +++++++++++++++++++ .../frontend/states/chat_state.py | 20 +++++++ 6 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 src/witty_wisterias/frontend/components/user_info.py diff --git a/src/witty_wisterias/backend/backend.py b/src/witty_wisterias/backend/backend.py index 3763b34c..8e640e6b 100644 --- a/src/witty_wisterias/backend/backend.py +++ b/src/witty_wisterias/backend/backend.py @@ -6,7 +6,7 @@ from .cryptographer import Cryptographer from .database import Database from .exceptions import InvalidDataError -from .message_format import EventType, MessageFormat +from .message_format import EventType, ExtraEventInfo, MessageFormat @dataclass @@ -149,6 +149,10 @@ def send_public_message(message: MessageFormat) -> None: event_type=message.event_type, content=signed_message, timestamp=message.timestamp, + extra_event_info=ExtraEventInfo( + user_name=message.sender_username, + user_image=message.sender_profile_image, + ), ) queried_data.message_stack.append(public_message) @@ -191,6 +195,10 @@ def send_private_message(message: MessageFormat) -> None: event_type=message.event_type, content=encrypted_message, timestamp=message.timestamp, + extra_event_info=ExtraEventInfo( + user_name=message.sender_username, + user_image=message.sender_profile_image, + ), ) queried_data.message_stack.append(private_message) diff --git a/src/witty_wisterias/backend/message_format.py b/src/witty_wisterias/backend/message_format.py index 431ea012..c3a62016 100644 --- a/src/witty_wisterias/backend/message_format.py +++ b/src/witty_wisterias/backend/message_format.py @@ -58,6 +58,8 @@ class MessageFormat: event_type: EventType content: str timestamp: float + sender_username: str = field(default="") + sender_profile_image: str = field(default="") receiver_id: str = field(default="None") signing_key: str = field(default="") verify_key: str = field(default="") @@ -73,12 +75,12 @@ def to_dict(self) -> MessageFormatJson: "sender_id": self.sender_id, "receiver_id": self.receiver_id, "event_type": self.event_type.name, + "timestamp": self.timestamp, "signing_key": self.signing_key, "verify_key": self.verify_key, "own_public_key": self.own_public_key, "private_key": self.private_key, "receiver_public_key": self.receiver_public_key, - "timestamp": self.timestamp, }, "body": {"content": self.content, "extra_event_info": self.extra_event_info.to_dict()}, } diff --git a/src/witty_wisterias/frontend/components/chat_bubble.py b/src/witty_wisterias/frontend/components/chat_bubble.py index 9a9a82e0..b8fbdddf 100644 --- a/src/witty_wisterias/frontend/components/chat_bubble.py +++ b/src/witty_wisterias/frontend/components/chat_bubble.py @@ -27,7 +27,7 @@ def chat_bubble_component( """ avatar = rx.avatar( src=user_profile_image, - fallback=user_name[:2], + fallback=user_id[:2], radius="large", size="3", ) diff --git a/src/witty_wisterias/frontend/components/sidebar.py b/src/witty_wisterias/frontend/components/sidebar.py index ca0ae030..06d60709 100644 --- a/src/witty_wisterias/frontend/components/sidebar.py +++ b/src/witty_wisterias/frontend/components/sidebar.py @@ -1,6 +1,7 @@ import reflex as rx from frontend.components.create_chat import create_chat_component +from frontend.components.user_info import user_info_component from frontend.states.chat_state import ChatState from frontend.states.progress_state import ProgressState @@ -56,15 +57,21 @@ def chat_sidebar() -> rx.Component: rx.heading(ProgressState.progress, size="2", class_name="text-gray-500"), rx.divider(), rx.hstack( - rx.avatar( - src=ChatState.user_profile_image, fallback=ChatState.user_id[:2], radius="large", size="3" + rx.hstack( + rx.avatar( + src=ChatState.user_profile_image, fallback=ChatState.user_id[:2], radius="large", size="3" + ), + rx.vstack( + rx.text(ChatState.user_name | ChatState.user_id, size="3"), + rx.text(ChatState.user_id, size="1", class_name="text-gray-500"), + spacing="0", + ), ), - rx.vstack( - rx.text(ChatState.user_name | ChatState.user_id, size="3"), - rx.text(ChatState.user_id, size="1", class_name="text-gray-500"), - spacing="0", - ), - class_name="mt-1", + user_info_component(), + spacing="2", + align="center", + justify="between", + class_name="mt-1 w-full", ), class_name="mt-auto mb-7 w-full", ), diff --git a/src/witty_wisterias/frontend/components/user_info.py b/src/witty_wisterias/frontend/components/user_info.py new file mode 100644 index 00000000..0887dd02 --- /dev/null +++ b/src/witty_wisterias/frontend/components/user_info.py @@ -0,0 +1,57 @@ +import reflex as rx + +from frontend.states.chat_state import ChatState + + +def user_info_component() -> rx.Component: + """The dialog (and button) for editing the user information""" + return rx.dialog.root( + rx.dialog.trigger( + rx.button( + rx.icon("user-pen", size=25, class_name="text-gray-500"), + class_name="bg-white", + ) + ), + rx.dialog.content( + rx.dialog.title("Edit your User Information"), + rx.dialog.description( + "Here you can edit your user information, including your username and profile picture.", + size="2", + margin_bottom="16px", + ), + rx.form( + rx.vstack( + rx.input( + placeholder="Enter your new Username", + default_value=ChatState.user_name, + name="user_name", + required=True, + variant="surface", + class_name="w-full", + ), + rx.input( + placeholder="Enter your new Profile Picture URL", + name="user_profile_image", + variant="surface", + class_name="w-full", + ), + rx.hstack( + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ), + ), + rx.dialog.close( + rx.button("Send", type="submit"), + ), + ), + spacing="3", + margin_top="16px", + justify="end", + ), + on_submit=ChatState.edit_user_info, + ), + ), + ) diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index e11db0a3..fb0e8552 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -207,6 +207,18 @@ def accept_tos(self) -> Generator[None, None]: self.tos_accepted = "True" yield + @rx.event + def edit_user_info(self, form_data: dict[str, str]) -> Generator[None, None]: + """ + Reflex Event when the user information is edited. + + Args: + form_data (dict[str, str]): The form data containing the user information. + """ + self.user_name = form_data.get("user_name", "").strip() + self.user_profile_image = form_data.get("user_profile_image", "").strip() or None + yield + @rx.event def select_chat(self, chat_name: str) -> Generator[None, None]: """ @@ -255,6 +267,8 @@ def send_public_text(self, form_data: dict[str, str]) -> Generator[None, None]: timestamp=message_timestamp, signing_key=self.signing_key, verify_key=self.get_key_storage("verify_keys")[self.user_id], + sender_username=self.user_name, + sender_profile_image=self.user_profile_image, ) # Note: We need to use threading here, even if it looks odd. This is because the # Backend.send_public_message method blocks the UI thread. So we need to run it in a separate thread. @@ -304,6 +318,8 @@ def send_public_image(self, form_data: dict[str, str]) -> Generator[None, None]: timestamp=message_timestamp, signing_key=self.signing_key, verify_key=self.get_key_storage("verify_keys")[self.user_id], + sender_username=self.user_name, + sender_profile_image=self.user_profile_image, ) # Note: We need to use threading here, even if it looks odd. This is because the # Backend.send_public_message method blocks the UI thread. So we need to run it in a separate thread. @@ -365,6 +381,8 @@ def send_private_text(self, form_data: dict[str, str]) -> Generator[None, None]: own_public_key=self.get_key_storage("public_keys")[self.user_id], receiver_public_key=self.get_key_storage("public_keys")[receiver_id], private_key=self.private_key, + sender_username=self.user_name, + sender_profile_image=self.user_profile_image, ) # Note: We need to use threading here, even if it looks odd. This is because the # Backend.send_private_message method blocks the UI thread. So we need to run it in a separate thread. @@ -429,6 +447,8 @@ def send_private_image(self, form_data: dict[str, str]) -> Generator[None, None] own_public_key=self.get_key_storage("public_keys")[self.user_id], receiver_public_key=self.get_key_storage("public_keys")[receiver_id], private_key=self.private_key, + sender_username=self.user_name, + sender_profile_image=self.user_profile_image, ) # Note: We need to use threading here, even if it looks odd. This is because the # Backend.send_private_message method blocks the UI thread. So we need to run it in a separate thread. From d272f7bb188374973f1caf6f9c352237f2ecbcce Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sun, 17 Aug 2025 18:16:26 +0200 Subject: [PATCH 43/56] Refractoring, Styling and Documentation --- src/witty_wisterias/backend/backend.py | 48 +++-- src/witty_wisterias/backend/database.py | 20 +- src/witty_wisterias/backend/message_format.py | 161 +++++++++++++++- .../frontend/components/chat_bubble.py | 13 +- .../frontend/components/chatapp.py | 14 +- .../frontend/components/create_chat.py | 27 ++- .../frontend/components/image_button.py | 19 +- .../frontend/components/sidebar.py | 8 +- .../frontend/components/text_button.py | 19 +- .../frontend/components/tos_accept_form.py | 7 +- .../frontend/components/user_info.py | 16 +- .../frontend/states/chat_state.py | 174 ++++-------------- 12 files changed, 289 insertions(+), 237 deletions(-) diff --git a/src/witty_wisterias/backend/backend.py b/src/witty_wisterias/backend/backend.py index 8e640e6b..b997dfd7 100644 --- a/src/witty_wisterias/backend/backend.py +++ b/src/witty_wisterias/backend/backend.py @@ -43,10 +43,7 @@ def from_json(data: str) -> "UploadStack": class Backend: - """ - Base class for the backend. - This class should be inherited by all backends. - """ + """Base class for the backend, which is used by the Frontend to handle Messages.""" @staticmethod def decode(encoded_stack: str) -> UploadStack: @@ -59,6 +56,7 @@ def decode(encoded_stack: str) -> UploadStack: Returns: list[MessageFormat]: A list of MessageFormat objects reconstructed from the decoded data. """ + # Check if the Message Stack is completely empty if not encoded_stack: return UploadStack() compressed_stack = base64.b64decode(encoded_stack.encode("utf-8")) @@ -99,6 +97,7 @@ def push_public_keys(user_id: str, verify_key: str, public_key: str) -> None: verify_key (str): The verify key of the user. public_key (str): The public key of the user. """ + # Query the latest Data from the Database queried_data = Backend.decode(Database.query_data()) # Append the verify_key to the Upload Stack if not already present @@ -109,6 +108,7 @@ def push_public_keys(user_id: str, verify_key: str, public_key: str) -> None: if user_id not in queried_data.public_keys_stack: queried_data.public_keys_stack[user_id] = public_key + # Upload the new Data to save it in the Database Database.upload_data(Backend.encode(queried_data)) @staticmethod @@ -120,6 +120,7 @@ def read_public_keys() -> tuple[dict[str, str], dict[str, str]]: dict[str, str]: A dictionary containing user IDs as keys and their verify keys as values. dict[str, str]: A dictionary containing user IDs as keys and their public keys as values. """ + # Query the latest Data from the Database queried_data = Backend.decode(Database.query_data()) return queried_data.verify_keys_stack, queried_data.public_keys_stack @@ -131,11 +132,10 @@ def send_public_message(message: MessageFormat) -> None: Args: message (MessageFormat): The message to be sent, containing senderID, event type, content, and signing key. """ - if not ( - message.sender_id and message.event_type and message.content and message.timestamp and message.signing_key - ): + if not (message.sender_id and message.event_type and message.content and message.signing_key): raise InvalidDataError("MessageFormat is not complete") + # Query the latest Data from the Backend queried_data = Backend.decode(Database.query_data()) # Append the verify_key to the Upload Stack if not already present @@ -144,6 +144,7 @@ def send_public_message(message: MessageFormat) -> None: # Sign the message using the Signing Key signed_message = Cryptographer.sign_message(message.content, message.signing_key) + # Create the Public Message to push public_message = MessageFormat( sender_id=message.sender_id, event_type=message.event_type, @@ -155,8 +156,10 @@ def send_public_message(message: MessageFormat) -> None: ), ) + # Push the new Public Message to the Message Stack queried_data.message_stack.append(public_message) + # Upload the new Data to save it in the Database Database.upload_data(Backend.encode(queried_data)) @staticmethod @@ -179,6 +182,7 @@ def send_private_message(message: MessageFormat) -> None: ): raise InvalidDataError("MessageFormat is not complete") + # Query the latest Data from the Database queried_data = Backend.decode(Database.query_data()) # Append own Public Key to the Upload Stack if not already present @@ -189,6 +193,7 @@ def send_private_message(message: MessageFormat) -> None: encrypted_message = Cryptographer.encrypt_message( message.content, message.private_key, message.receiver_public_key ) + # Create the Private Message to push private_message = MessageFormat( sender_id=message.sender_id, receiver_id=message.receiver_id, @@ -201,28 +206,34 @@ def send_private_message(message: MessageFormat) -> None: ), ) + # Push the new Public Message to the Message Stack queried_data.message_stack.append(private_message) + # Upload the new Data to save it in the Database Database.upload_data(Backend.encode(queried_data)) @staticmethod def read_public_messages() -> list[MessageFormat]: """ Read public text messages. - This method should be overridden by the backend. + + Returns: + list[MessageFormat]: A list of verified public messages. """ - decoded_data = Backend.decode(Database.query_data()) + # Query the latest Data from the Database + queried_data = Backend.decode(Database.query_data()) + # Only verifiable Messages should be displayed verified_messaged: list[MessageFormat] = [] - for message in decoded_data.message_stack: + for message in queried_data.message_stack: if isinstance(message, str): continue # Checking if the message is a public message if message.event_type not in (EventType.PUBLIC_TEXT, EventType.PUBLIC_IMAGE): continue # Signature Verification - if message.sender_id in decoded_data.verify_keys_stack: - verify_key = decoded_data.verify_keys_stack[message.sender_id] + if message.sender_id in queried_data.verify_keys_stack: + verify_key = queried_data.verify_keys_stack[message.sender_id] try: # Verify the message content using the verify key message.content = Cryptographer.verify_message(message.content, verify_key) @@ -236,7 +247,6 @@ def read_public_messages() -> list[MessageFormat]: def read_private_messages(user_id: str, private_key: str) -> list[MessageFormat]: """ Read private messages for a specific receiver. - This method should be overridden by the backend. Args: user_id (str): The ID of own user which tries to read private messages. @@ -245,20 +255,22 @@ def read_private_messages(user_id: str, private_key: str) -> list[MessageFormat] Returns: list[MessageFormat]: A list of decrypted private messages for the specified receiver. """ - decoded_data = Backend.decode(Database.query_data()) + # Query the latest Data from the Database + queried_data = Backend.decode(Database.query_data()) + # Only decryptable Messages should be displayed decrypted_messages: list[MessageFormat] = [] - for message in decoded_data.message_stack: + for message in queried_data.message_stack: if isinstance(message, str): continue # Checking if the message is a private message if message.event_type not in (EventType.PRIVATE_TEXT, EventType.PRIVATE_IMAGE): continue # Message Decryption check - if message.receiver_id == user_id and message.sender_id in decoded_data.public_keys_stack: + if message.receiver_id == user_id and message.sender_id in queried_data.public_keys_stack: try: - sender_public_key = decoded_data.public_keys_stack[message.sender_id] - # Decrypt the message content using the receiver's private key + sender_public_key = queried_data.public_keys_stack[message.sender_id] + # Decrypt the message content using the receiver's private key and the sender's public key decrypted_content = Cryptographer.decrypt_message(message.content, private_key, sender_public_key) message.content = decrypted_content decrypted_messages.append(message) diff --git a/src/witty_wisterias/backend/database.py b/src/witty_wisterias/backend/database.py index 2f723f5c..709eee18 100644 --- a/src/witty_wisterias/backend/database.py +++ b/src/witty_wisterias/backend/database.py @@ -16,18 +16,13 @@ HTTP_SESSION = httpx.Client(timeout=30) # Image Hoster URL and API Endpoints -HOSTER_URL = "https://freeimghost.net/" -UPLOAD_URL = HOSTER_URL + "upload" -JSON_URL = HOSTER_URL + "json" +HOSTER_URL = "https://freeimghost.net" +UPLOAD_URL = HOSTER_URL + "/upload" +JSON_URL = HOSTER_URL + "/json" # Search Term used to query for our images (and name our files) FILE_SEARCH_TERM = "WittyWisteriasV7" -def search_url(query: str) -> str: - """Insert the query into the url and return it""" - return HOSTER_URL + f"search/images/?q={query}" - - class Database: """ Our Database, responsible for storing and retrieving data. @@ -169,7 +164,7 @@ def query_data() -> str: InvalidResponseError: If the query fails or the response is not as expected. """ # Query all images with the FILE_SEARCH_TERM from the image hosting service - response = HTTP_SESSION.get(search_url(FILE_SEARCH_TERM)) + response = HTTP_SESSION.get(f"{HOSTER_URL}/search/images/?q={FILE_SEARCH_TERM}") # Check if the response is successful if response.status_code != 200: raise InvalidResponseError("Failed to query latest image from the image hosting service.") @@ -223,10 +218,3 @@ def upload_data(data: str) -> None: image_bytes = Database.base64_to_image(bytes_data) # Upload the image bytes to the Image Hosting Service Database.upload_image(image_bytes) - - -if __name__ == "__main__": - # Example/Testing usage - db = Database() - db.upload_data("Hello, World! Witty Wisterias here.") - print(db.query_data()) diff --git a/src/witty_wisterias/backend/message_format.py b/src/witty_wisterias/backend/message_format.py index c3a62016..008d6d3b 100644 --- a/src/witty_wisterias/backend/message_format.py +++ b/src/witty_wisterias/backend/message_format.py @@ -1,11 +1,13 @@ +import base64 import json from dataclasses import dataclass, field from enum import Enum, auto +from io import BytesIO from typing import TypedDict +from PIL import Image + -# TODO: We should probably move types to a separate file for better organization (and exceptions.py too), doesnt belong -# in /modules/... class EventType(Enum): """Enumeration for different task types.""" @@ -13,8 +15,6 @@ class EventType(Enum): PUBLIC_IMAGE = auto() PRIVATE_TEXT = auto() PRIVATE_IMAGE = auto() - SET_USERNAME = auto() - SET_PROFILEPICTURE = auto() class MessageFormatJson(TypedDict): @@ -35,7 +35,12 @@ class ExtraEventInfo: user_image: str | None = field(default=None) def to_dict(self) -> dict[str, str | None]: - """Convert the extra event info to a dictionary.""" + """ + Convert the extra event info to a dictionary. + + Returns: + dict[str, str | None]: The Extra Event Info in a dict format. + """ return { "user_name": self.user_name, "user_image": self.user_image, @@ -43,14 +48,22 @@ def to_dict(self) -> dict[str, str | None]: @staticmethod def from_json(data: dict[str, str | None]) -> "ExtraEventInfo": - """Deserialize a JSON string into an ExtraEventInfo object.""" + """ + Deserialize a JSON string into an ExtraEventInfo object. + + Args: + data (dict[str, str | None]): The Extra Event Info in a dict format. + + Returns: + ExtraEventInfo: : The Extra Event Info in a ExtraEventInfo object. + """ return ExtraEventInfo(user_name=data.get("user_name", ""), user_image=data.get("user_image", "")) @dataclass class MessageFormat: """ - Defines the standard structure for messages in the system. + Defines the standard structure for messages in the backend. Supports serialization/deserialization for storage in images. """ @@ -69,7 +82,12 @@ class MessageFormat: extra_event_info: ExtraEventInfo = field(default_factory=ExtraEventInfo) def to_dict(self) -> MessageFormatJson: - """Convert the message into a Python dictionary.""" + """ + Convert the message into a Python dictionary. + + Returns: + MessageFormatJson: The MessageFormat encoded in a dict. + """ return { "header": { "sender_id": self.sender_id, @@ -86,12 +104,25 @@ def to_dict(self) -> MessageFormatJson: } def to_json(self) -> str: - """Serialize the message into a JSON string.""" + """ + Serialize the message into a JSON string. + + Returns: + str: The MessageFormat encoded in a JSON String. + """ return json.dumps(self.to_dict(), ensure_ascii=False) @staticmethod def from_json(data: str) -> "MessageFormat": - """Deserialize a JSON string into a MessageFormat object.""" + """ + Deserialize a JSON string into a MessageFormat object. + + Args: + data (str): The MessageFormat encoded in a JSON String. + + Returns: + MessageFormat: The Message Info in a MessageFormat object. + """ obj = json.loads(data) return MessageFormat( sender_id=obj["header"]["sender_id"], @@ -106,3 +137,113 @@ def from_json(data: str) -> "MessageFormat": content=obj["body"]["content"], extra_event_info=ExtraEventInfo.from_json(obj["body"].get("extra_event_info", {})), ) + + +class MessageStateJson(TypedDict): + """ + Defines the structure of the JSON representation of a message. + This is used for serialization and deserialization of messages. + """ + + message: str + user_id: str + receiver_id: str | None + user_name: str + user_profile_image: str | None + own_message: bool + is_image_message: bool + timestamp: float + + +@dataclass +class MessageState: + """A message in the chat application state (Frontend).""" + + message: str | Image.Image + user_id: str + receiver_id: str | None + user_name: str + user_profile_image: str | None + own_message: bool + is_image_message: bool + timestamp: float + + @staticmethod + def from_message_format(message_format: MessageFormat, user_id: str) -> "MessageState": + """ + Convert a MessageFormat object to a Message object. + + Args: + message_format (MessageFormat): The MessageFormat object to convert. + user_id (str): The ChatState.user_id as str. + + Returns: + Message: A Message object created from the MessageFormat. + """ + return MessageState( + message=message_format.content, + user_id=message_format.sender_id, + receiver_id=message_format.receiver_id if message_format.receiver_id != "None" else None, + user_name=message_format.extra_event_info.user_name or message_format.sender_id, + user_profile_image=message_format.extra_event_info.user_image, + own_message=user_id == message_format.sender_id, + is_image_message=message_format.event_type in (EventType.PUBLIC_IMAGE, EventType.PRIVATE_IMAGE), + timestamp=message_format.timestamp, + ) + + @staticmethod + def from_dict(data: MessageStateJson) -> "MessageState": + """ + Convert a dictionary to a Message object. + + Args: + data (dict[str, str]): The dictionary containing message data. + + Returns: + Message: A Message object created from the dictionary. + """ + # Convert the base64 message content to a Pillow Image if it is an image message + if data.get("is_image_message", False): + # Decode the base64 image data to an Image object + image_data = base64.b64decode(data["message"]) + message_content = Image.open(BytesIO(image_data)) + message_content = message_content.convert("RGB") + else: + message_content = data["message"] + return MessageState( + message=message_content, + user_id=data["user_id"], + receiver_id=data.get("receiver_id"), + user_name=data["user_name"], + user_profile_image=data.get("user_profile_image"), + own_message=data.get("own_message", False), + is_image_message=data.get("is_image_message", False), + timestamp=float(data["timestamp"]), + ) + + def to_dict(self) -> MessageStateJson: + """ + Convert the message into a Python dictionary. + + Returns: + MessageJson: A dictionary representation of the message. + """ + # Convert the Image to Base64 if it is an Image Message + if isinstance(self.message, Image.Image): + # Convert the image to bytes and encode it in base64 (JPEG to save limited LocalStorage space) + buffered = BytesIO() + self.message.save(buffered, format="JPEG") + message_data = base64.b64encode(buffered.getvalue()).decode("utf-8") + else: + message_data = self.message + + return { + "message": message_data, + "user_id": self.user_id, + "receiver_id": self.receiver_id, + "user_name": self.user_name, + "user_profile_image": self.user_profile_image, + "own_message": self.own_message, + "is_image_message": self.is_image_message, + "timestamp": self.timestamp, + } diff --git a/src/witty_wisterias/frontend/components/chat_bubble.py b/src/witty_wisterias/frontend/components/chat_bubble.py index b8fbdddf..2109b771 100644 --- a/src/witty_wisterias/frontend/components/chat_bubble.py +++ b/src/witty_wisterias/frontend/components/chat_bubble.py @@ -12,7 +12,8 @@ def chat_bubble_component( own_message: bool = False, is_image_message: bool = False, ) -> rx.Component: - """Creates a chat bubble component for displaying messages in the chat application. + """ + Creates a chat bubble component for displaying messages in the chat application. Args: message (str): The content of the message, either text or base64-encoded image. @@ -25,12 +26,7 @@ def chat_bubble_component( Returns: rx.Component: A component representing the chat bubble. """ - avatar = rx.avatar( - src=user_profile_image, - fallback=user_id[:2], - radius="large", - size="3", - ) + avatar = rx.avatar(src=user_profile_image, fallback=user_id[:2], radius="large", size="3") message_content = rx.vstack( rx.text(user_name, class_name="font-semibold text-gray-600"), rx.cond( @@ -42,6 +38,8 @@ def chat_bubble_component( spacing="0", ) + # If the message is our own the Avatar should be on the right of the message content + # If it is not, it should be on the left. chat_bubble = rx.hstack( rx.cond( own_message, @@ -51,5 +49,6 @@ def chat_bubble_component( class_name="items-start space-x-2 bg-gray-100 p-4 rounded-lg shadow-sm", style={"alignSelf": rx.cond(own_message, "flex-end", "flex-start")}, ) + # Allow creating a private chat by clicking on others user's chat message return rx.cond(own_message, chat_bubble, create_chat_component(chat_bubble, user_id)) diff --git a/src/witty_wisterias/frontend/components/chatapp.py b/src/witty_wisterias/frontend/components/chatapp.py index 4f839137..0186ab38 100644 --- a/src/witty_wisterias/frontend/components/chatapp.py +++ b/src/witty_wisterias/frontend/components/chatapp.py @@ -16,6 +16,7 @@ def chat_specific_messages(message: Message) -> rx.Component: Returns: rx.Component: A component representing the chat bubble for the message, if it fits. """ + # Storing Message/User Attributes for easier access user_id = message.get("user_id") receiver_id = message.get("receiver_id") selected_chat = ChatState.selected_chat @@ -49,7 +50,12 @@ def chat_specific_messages(message: Message) -> rx.Component: def chat_app() -> rx.Component: - """Main chat application component.""" + """ + Main chat application component. + + Returns: + rx.Component: The main chat application component. + """ return rx.vstack( rx.heading( rx.cond( @@ -69,11 +75,7 @@ def chat_app() -> rx.Component: class_name="flex flex-col gap-4 pb-6 pt-3 h-full w-full bg-gray-50 p-5 rounded-xl shadow-sm", ), rx.divider(), - rx.hstack( - send_text_component(), - send_image_component(), - class_name="mt-auto mb-3 w-full", - ), + rx.hstack(send_text_component(), send_image_component(), class_name="mt-auto mb-3 w-full"), spacing="4", class_name="h-screen w-full mx-5", ) diff --git a/src/witty_wisterias/frontend/components/create_chat.py b/src/witty_wisterias/frontend/components/create_chat.py index 9143d0bb..e960e4a2 100644 --- a/src/witty_wisterias/frontend/components/create_chat.py +++ b/src/witty_wisterias/frontend/components/create_chat.py @@ -4,15 +4,22 @@ def create_chat_component(create_chat_button: rx.Component, user_id: str | None = None) -> rx.Component: - """The create-new-chat button, which spawns a dialog to create a new private chat.""" + """ + The create-new-chat button, which spawns a dialog to create a new private chat. + + Args: + create_chat_button (rx.Component): The Component which triggers the Create-Chat-Dialog. + user_id (str | None): The UserID to default to. + + Returns: + rx.Component: The Create Chat Form, with the create_chat_button as the trigger. + """ return rx.dialog.root( rx.dialog.trigger(create_chat_button), rx.dialog.content( rx.dialog.title("Create new Private Chat"), rx.dialog.description( - "Create a new Private Chat with a user by entering their User ID.", - size="2", - margin_bottom="16px", + "Create a new Private Chat with a user by entering their User ID.", size="2", margin_bottom="16px" ), rx.form( rx.vstack( @@ -34,16 +41,8 @@ def create_chat_component(create_chat_button: rx.Component, user_id: str | None class_name="w-full", ), rx.hstack( - rx.dialog.close( - rx.button( - "Cancel", - variant="soft", - color_scheme="gray", - ), - ), - rx.dialog.close( - rx.button("Send", type="submit"), - ), + rx.dialog.close(rx.button("Cancel", variant="soft", color_scheme="gray")), + rx.dialog.close(rx.button("Send", type="submit")), ), spacing="3", margin_top="16px", diff --git a/src/witty_wisterias/frontend/components/image_button.py b/src/witty_wisterias/frontend/components/image_button.py index ffa192cf..6819812a 100644 --- a/src/witty_wisterias/frontend/components/image_button.py +++ b/src/witty_wisterias/frontend/components/image_button.py @@ -21,16 +21,8 @@ def image_form() -> rx.Component: class_name="w-full", ), rx.hstack( - rx.dialog.close( - rx.button( - "Cancel", - variant="soft", - color_scheme="gray", - ), - ), - rx.dialog.close( - rx.button("Send", type="submit"), - ), + rx.dialog.close(rx.button("Cancel", variant="soft", color_scheme="gray")), + rx.dialog.close(rx.button("Send", type="submit")), ), spacing="3", margin_top="16px", @@ -39,7 +31,12 @@ def image_form() -> rx.Component: def send_image_component() -> rx.Component: - """The dialog (and button) for sending an image""" + """ + The dialog (and button) for sending an image + + Returns: + rx.Component: The Image Button Component, which triggers the Image Message Form. + """ return rx.dialog.root( rx.dialog.trigger( rx.button( diff --git a/src/witty_wisterias/frontend/components/sidebar.py b/src/witty_wisterias/frontend/components/sidebar.py index 06d60709..e71cb27a 100644 --- a/src/witty_wisterias/frontend/components/sidebar.py +++ b/src/witty_wisterias/frontend/components/sidebar.py @@ -7,7 +7,13 @@ def chat_sidebar() -> rx.Component: - """Sidebar component for the chat application, which allows users to select different chats.""" + """ + Sidebar component for the chat application, which allows users to select the public Chat, different private + Chats and to view and edit their own User Information. + + Returns: + rx.Component: The Chat Sidebar Component. + """ return rx.el.div( rx.vstack( rx.hstack( diff --git a/src/witty_wisterias/frontend/components/text_button.py b/src/witty_wisterias/frontend/components/text_button.py index 15498471..511e03da 100644 --- a/src/witty_wisterias/frontend/components/text_button.py +++ b/src/witty_wisterias/frontend/components/text_button.py @@ -21,16 +21,8 @@ def text_form() -> rx.Component: class_name="w-full", ), rx.hstack( - rx.dialog.close( - rx.button( - "Cancel", - variant="soft", - color_scheme="gray", - ), - ), - rx.dialog.close( - rx.button("Send", type="submit"), - ), + rx.dialog.close(rx.button("Cancel", variant="soft", color_scheme="gray")), + rx.dialog.close(rx.button("Send", type="submit")), ), spacing="3", margin_top="16px", @@ -39,7 +31,12 @@ def text_form() -> rx.Component: def send_text_component() -> rx.Component: - """The dialog (and button) for sending texts""" + """ + The dialog (and button) for sending texts. + + Returns: + rx.Component: The Text Button Component, which triggers the Text Message Form. + """ # TODO: This should be replaced with the Webcam handler, text will do for now return rx.dialog.root( rx.dialog.trigger( diff --git a/src/witty_wisterias/frontend/components/tos_accept_form.py b/src/witty_wisterias/frontend/components/tos_accept_form.py index 96621e89..3417be0f 100644 --- a/src/witty_wisterias/frontend/components/tos_accept_form.py +++ b/src/witty_wisterias/frontend/components/tos_accept_form.py @@ -4,7 +4,12 @@ def tos_accept_form() -> rx.Component: - """Terms of Service Accept Form""" + """ + Terms of Service Accept Form. + + Returns: + rx.Component: The Terms of Service Accept Form. + """ return rx.form( rx.vstack( rx.text("You hereby accept the Terms of Service of:"), diff --git a/src/witty_wisterias/frontend/components/user_info.py b/src/witty_wisterias/frontend/components/user_info.py index 0887dd02..c5c03cae 100644 --- a/src/witty_wisterias/frontend/components/user_info.py +++ b/src/witty_wisterias/frontend/components/user_info.py @@ -4,14 +4,14 @@ def user_info_component() -> rx.Component: - """The dialog (and button) for editing the user information""" + """ + The dialog (and button) for editing the user information. + + Returns: + rx.Component: The User Info Edit Button, which triggers the User Info Edit Form. + """ return rx.dialog.root( - rx.dialog.trigger( - rx.button( - rx.icon("user-pen", size=25, class_name="text-gray-500"), - class_name="bg-white", - ) - ), + rx.dialog.trigger(rx.button(rx.icon("user-pen", size=25, class_name="text-gray-500"), class_name="bg-white")), rx.dialog.content( rx.dialog.title("Edit your User Information"), rx.dialog.description( @@ -41,7 +41,7 @@ def user_info_component() -> rx.Component: "Cancel", variant="soft", color_scheme="gray", - ), + ) ), rx.dialog.close( rx.button("Send", type="submit"), diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index fb0e8552..1b052cf7 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -1,137 +1,28 @@ import asyncio -import base64 import io import json import threading from collections.abc import AsyncGenerator, Generator -from dataclasses import dataclass from datetime import UTC, datetime -from typing import Literal, TypedDict, cast +from typing import Literal, cast import reflex as rx import requests from backend.backend import Backend from backend.cryptographer import Cryptographer -from backend.message_format import EventType, MessageFormat +from backend.message_format import EventType, MessageFormat, MessageState from PIL import Image from frontend.states.progress_state import ProgressState -class MessageJson(TypedDict): - """ - Defines the structure of the JSON representation of a message. - This is used for serialization and deserialization of messages. - """ - - message: str - user_id: str - receiver_id: str | None - user_name: str - user_profile_image: str | None - own_message: bool - is_image_message: bool - timestamp: float - - -@dataclass -class Message: - """A message in the chat application.""" - - message: str | Image.Image - user_id: str - receiver_id: str | None - user_name: str - user_profile_image: str | None - own_message: bool - is_image_message: bool - timestamp: float - - @staticmethod - def from_message_format(message_format: MessageFormat) -> "Message": - """ - Convert a MessageFormat object to a Message object. - - Args: - message_format (MessageFormat): The MessageFormat object to convert. - - Returns: - Message: A Message object created from the MessageFormat. - """ - return Message( - message=message_format.content, - user_id=message_format.sender_id, - receiver_id=message_format.receiver_id if message_format.receiver_id != "None" else None, - user_name=message_format.extra_event_info.user_name or message_format.sender_id, - user_profile_image=message_format.extra_event_info.user_image, - own_message=str(ChatState.user_id) == message_format.sender_id, - is_image_message=message_format.event_type in (EventType.PUBLIC_IMAGE, EventType.PRIVATE_IMAGE), - timestamp=message_format.timestamp, - ) - - @staticmethod - def from_dict(data: MessageJson) -> "Message": - """ - Convert a dictionary to a Message object. - - Args: - data (dict[str, str]): The dictionary containing message data. - - Returns: - Message: A Message object created from the dictionary. - """ - if data.get("is_image_message", False): - # Decode the base64 image data to an Image object - image_data = base64.b64decode(data["message"]) - message_content = Image.open(io.BytesIO(image_data)) - message_content = message_content.convert("RGB") - else: - message_content = data["message"] - return Message( - message=message_content, - user_id=data["user_id"], - receiver_id=data.get("receiver_id"), - user_name=data["user_name"], - user_profile_image=data.get("user_profile_image"), - own_message=data.get("own_message", False), - is_image_message=data.get("is_image_message", False), - timestamp=float(data["timestamp"]), - ) - - def to_dict(self) -> MessageJson: - """ - Convert the message into a Python dictionary. - - Returns: - MessageJson: A dictionary representation of the message. - """ - if isinstance(self.message, Image.Image): - # Convert the image to bytes and encode it in base64 (JPEG to save limited LocalStorage space) - buffered = io.BytesIO() - self.message.save(buffered, format="JPEG") - message_data = base64.b64encode(buffered.getvalue()).decode("utf-8") - else: - message_data = self.message - - return { - "message": message_data, - "user_id": self.user_id, - "receiver_id": self.receiver_id, - "user_name": self.user_name, - "user_profile_image": self.user_profile_image, - "own_message": self.own_message, - "is_image_message": self.is_image_message, - "timestamp": self.timestamp, - } - - class ChatState(rx.State): - """The Chat app state, used to handle Messages.""" + """The Chat app state, used to handle Messages. Main Frontend Entrypoint.""" # Tos Accepted (Note: We need to use a string here because LocalStorage does not support booleans) tos_accepted: str = rx.LocalStorage("False", name="tos_accepted", sync=True) # List of Messages - messages: list[Message] = rx.field(default_factory=list) + messages: list[MessageState] = rx.field(default_factory=list) # We need to store our own private messages in LocalStorage, as we cannot decrypt them from the Database own_private_messages: str = rx.LocalStorage("[]", name="private_messages", sync=True) # Chat Partners @@ -162,6 +53,7 @@ def get_key_storage(self, storage_name: Literal["verify_keys", "public_keys"]) - dict[str, str]: A dictionary containing the keys and their corresponding values. """ storage = self.__getattribute__(f"{storage_name}_storage") + # Note: Casting Type as json.loads returns typing.Any return cast("dict[str, str]", json.loads(storage)) def dump_key_storage(self, storage_name: Literal["verify_keys", "public_keys"], value: dict[str, str]) -> None: @@ -185,10 +77,14 @@ def add_key_storage( user_id (str): The user ID to add. verify_key (str): The key to associate with the user ID. """ + # Loading the Key Storage into a dict current_keys = self.get_key_storage(storage_name) + # Adding the Key current_keys[user_id] = verify_key + # Dumping the new Key Storage self.dump_key_storage(storage_name, current_keys) + # Registering Private Chat Partners to show them in the Private Chats list def register_chat_partner(self, user_id: str) -> None: """ Register a new chat partner by adding their user ID to the chat partners list. @@ -196,6 +92,7 @@ def register_chat_partner(self, user_id: str) -> None: Args: user_id (str): The user ID of the chat partner to register. """ + # Avoid Duplicates if user_id not in self.chat_partners: self.chat_partners.append(user_id) # Sort to find the chat partner in the list more easily @@ -216,6 +113,7 @@ def edit_user_info(self, form_data: dict[str, str]) -> Generator[None, None]: form_data (dict[str, str]): The form data containing the user information. """ self.user_name = form_data.get("user_name", "").strip() + # A User Profile Image is not required in the Form. self.user_profile_image = form_data.get("user_profile_image", "").strip() or None yield @@ -244,13 +142,12 @@ def send_public_text(self, form_data: dict[str, str]) -> Generator[None, None]: yield ProgressState.public_message_progress message_timestamp = datetime.now(UTC).timestamp() - # Appending new own message + # Appending new own message to show in the Chat self.messages.append( - Message( + MessageState( message=message, user_id=self.user_id, user_name=self.user_name, - receiver_id=None, user_profile_image=self.user_profile_image, own_message=True, is_image_message=False, @@ -259,7 +156,7 @@ def send_public_text(self, form_data: dict[str, str]) -> Generator[None, None]: ) yield - # Posting message to backend + # Formatting the message for the Backend message_format = MessageFormat( sender_id=self.user_id, event_type=EventType.PUBLIC_TEXT, @@ -295,13 +192,12 @@ def send_public_image(self, form_data: dict[str, str]) -> Generator[None, None]: yield ProgressState.public_message_progress message_timestamp = datetime.now(UTC).timestamp() - # Appending new own message + # Appending new own message to show in the Chat self.messages.append( - Message( + MessageState( message=img, user_id=self.user_id, user_name=self.user_name, - receiver_id=None, user_profile_image=self.user_profile_image, own_message=True, is_image_message=True, @@ -310,7 +206,7 @@ def send_public_image(self, form_data: dict[str, str]) -> Generator[None, None]: ) yield - # Posting message to backend + # Formatting the message for the Backend message_format = MessageFormat( sender_id=self.user_id, event_type=EventType.PUBLIC_IMAGE, @@ -343,6 +239,7 @@ def send_private_text(self, form_data: dict[str, str]) -> Generator[None, None]: # Cant message someone who is not registered raise ValueError("Recipients Public Key is not registered.") + # Register Chat Partner and select the Chat self.register_chat_partner(receiver_id) self.selected_chat = receiver_id yield @@ -351,8 +248,8 @@ def send_private_text(self, form_data: dict[str, str]) -> Generator[None, None]: yield ProgressState.private_message_progress message_timestamp = datetime.now(UTC).timestamp() - # Appending new own message - chat_message = Message( + # Appending new own message to show in the Chat + chat_message = MessageState( message=message, user_id=self.user_id, user_name=self.user_name, @@ -364,14 +261,14 @@ def send_private_text(self, form_data: dict[str, str]) -> Generator[None, None]: ) self.messages.append(chat_message) - # Also append to own private messages, as we cannot decrypt them from the Database + # Also append to own private messages LocalStorage, as we cannot decrypt them from the Database own_private_messages_json = json.loads(self.own_private_messages) own_private_messages_json.append(chat_message.to_dict()) # Encode back to String JSON self.own_private_messages = json.dumps(own_private_messages_json) yield - # Posting message to backend + # Formatting the message for the Backend message_format = MessageFormat( sender_id=self.user_id, receiver_id=receiver_id, @@ -406,6 +303,7 @@ def send_private_image(self, form_data: dict[str, str]) -> Generator[None, None] # Cant message someone who is not registered raise ValueError("Recipients Public Key is not registered.") + # Register Chat Partner and select the Chat self.register_chat_partner(receiver_id) self.selected_chat = receiver_id yield @@ -418,8 +316,8 @@ def send_private_image(self, form_data: dict[str, str]) -> Generator[None, None] yield ProgressState.private_message_progress message_timestamp = datetime.now(UTC).timestamp() - # Appending new own message - chat_message = Message( + # Appending new own message to show in the Chat + chat_message = MessageState( message=img, user_id=self.user_id, user_name=self.user_name, @@ -437,7 +335,7 @@ def send_private_image(self, form_data: dict[str, str]) -> Generator[None, None] self.own_private_messages = json.dumps(own_private_messages_json) yield - # Posting message to backend + # Formatting the message for the Backend message_format = MessageFormat( sender_id=self.user_id, receiver_id=receiver_id, @@ -464,6 +362,7 @@ async def check_messages(self) -> None: async with self: # Read Verify and Public Keys from Backend verify_keys, public_keys = Backend.read_public_keys() + # Push Verify and Public Keys to the LocalStorage for user_id, verify_key in verify_keys.items(): self.add_key_storage("verify_keys", user_id, verify_key) for user_id, public_key in public_keys.items(): @@ -473,14 +372,15 @@ async def check_messages(self) -> None: for message in Backend.read_public_messages(): # Check if the message is already in the chat using timestamp message_exists = any( - msg.timestamp == message.timestamp and msg.user_id == message.sender_id + msg["timestamp"] == message.timestamp and msg["user_id"] == message.sender_id for msg in self.messages ) # Check if message is not already in the chat if not message_exists: + # Convert the Backend Format to the Frontend Format (MessageState) self.messages.append( - Message( + MessageState( message=message.content, user_id=message.sender_id, user_name=message.extra_event_info.user_name, @@ -491,13 +391,19 @@ async def check_messages(self) -> None: timestamp=message.timestamp, ) ) - # Private Chat Messages + + # Private Chat Messages stored in the Backend backend_private_message_formats = Backend.read_private_messages(self.user_id, self.private_key) backend_private_messages = [ - Message.from_message_format(message_format) for message_format in backend_private_message_formats + MessageState.from_message_format(message_format, str(self.user_id)) + for message_format in backend_private_message_formats ] + # Our own Private Messages, stored in the LocalStorage as we cannot self-decrypt them from the Backend own_private_messages_json = json.loads(self.own_private_messages) - own_private_messages = [Message.from_dict(message_data) for message_data in own_private_messages_json] + own_private_messages = [ + MessageState.from_dict(message_data) for message_data in own_private_messages_json + ] + # Sort them based on their timestamp sorted_private_messages = sorted( backend_private_messages + own_private_messages, key=lambda msg: msg.timestamp, @@ -520,7 +426,7 @@ async def check_messages(self) -> None: @rx.event async def startup_event(self) -> AsyncGenerator[None, None]: - """Reflex Event that is called when the app starts up.""" + """Reflex Event that is called when the app starts up. Main Entrypoint for the Frontend and spawns Backend.""" # Start Message Checking Background Task yield ChatState.check_messages From 4f96ad0021d1f3075bfdc1c09fdd13cd476fb91d Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Sun, 17 Aug 2025 18:19:08 +0200 Subject: [PATCH 44/56] Bug Fixes --- src/witty_wisterias/frontend/components/chatapp.py | 5 +++-- src/witty_wisterias/frontend/states/chat_state.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/witty_wisterias/frontend/components/chatapp.py b/src/witty_wisterias/frontend/components/chatapp.py index 0186ab38..fdb2c104 100644 --- a/src/witty_wisterias/frontend/components/chatapp.py +++ b/src/witty_wisterias/frontend/components/chatapp.py @@ -1,12 +1,13 @@ import reflex as rx +from backend.message_format import MessageState from frontend.components.chat_bubble import chat_bubble_component from frontend.components.image_button import send_image_component from frontend.components.text_button import send_text_component -from frontend.states.chat_state import ChatState, Message +from frontend.states.chat_state import ChatState -def chat_specific_messages(message: Message) -> rx.Component: +def chat_specific_messages(message: MessageState) -> rx.Component: """ Returns the correct chat bubble if the message is for the selected chat. diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index 1b052cf7..2246abb4 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -372,8 +372,8 @@ async def check_messages(self) -> None: for message in Backend.read_public_messages(): # Check if the message is already in the chat using timestamp message_exists = any( - msg["timestamp"] == message.timestamp and msg["user_id"] == message.sender_id - for msg in self.messages + all_messages.timestamp == message.timestamp and all_messages.timestamp == message.sender_id + for all_messages in self.messages ) # Check if message is not already in the chat From c3f8a13097974ae8b1514883f42131adba3c6f7c Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 18 Aug 2025 09:26:41 +0200 Subject: [PATCH 45/56] Delete modules/ --- src/witty_wisterias/modules/cryptographer.py | 158 ------------------- 1 file changed, 158 deletions(-) delete mode 100644 src/witty_wisterias/modules/cryptographer.py diff --git a/src/witty_wisterias/modules/cryptographer.py b/src/witty_wisterias/modules/cryptographer.py deleted file mode 100644 index 85cc0b47..00000000 --- a/src/witty_wisterias/modules/cryptographer.py +++ /dev/null @@ -1,158 +0,0 @@ -import base64 -import random - -from nacl.public import Box, PrivateKey, PublicKey -from nacl.signing import SigningKey, VerifyKey - - -class Cryptographer: - """ - A class to handle cryptographic operations of our chat App. - Handles Public-Key Encryption and Digital Signatures of messages. - """ - - @staticmethod - def generate_random_user_id() -> str: - """ - Generates a random UserID which will be 48bit (fits exactly 2 RGB pixels). - Note: The chance that two users having the same UserID is 1 in 281,474,976,710,656. - So for this CodeJam, we can safely assume that this will never happen and don't check for Duplicates. - - Returns: - str: A random 48-bit UserID encoded in base64. - """ - # Generate random 48-bit integer (0 to 2^48 - 1) - user_id_bits = random.getrandbits(48) - # Convert to bytes (6 bytes for 48 bits) - user_id_bytes = user_id_bits.to_bytes(6, byteorder="big") - # Encode to base64 - return base64.b64encode(user_id_bytes).decode("utf-8") - - @staticmethod - def generate_signing_key_pair() -> tuple[str, str]: - """ - Generates a new base64-encoded signing-verify key pair for signing messages. - - Returns: - str: The base64-encoded signing key. - str: The base64-encoded verify key. - """ - # Generate a new signing key - nacl_signing_key = SigningKey.generate() - nacl_verify_key = nacl_signing_key.verify_key - # Encode the keys in base64 - encoded_signing_key = base64.b64encode(nacl_signing_key.encode()).decode("utf-8") - encoded_verify_key = base64.b64encode(nacl_verify_key.encode()).decode("utf-8") - # Return the signing key and its verify key in base64 encoding - return encoded_signing_key, encoded_verify_key - - @staticmethod - def generate_encryption_key_pair() -> tuple[str, str]: - """ - Generates a new base64-encoded private-public key pair. - - Returns: - str: The base64-encoded private key. - str: The base64-encoded public key. - """ - # Generate a new private key - nacl_private_key = PrivateKey.generate() - nacl_public_key = nacl_private_key.public_key - # Encode the keys in base64 - encoded_private_key = base64.b64encode(nacl_private_key.encode()).decode("utf-8") - encoded_public_key = base64.b64encode(nacl_public_key.encode()).decode("utf-8") - # Return the private key and its public key in base64 encoding - return encoded_private_key, encoded_public_key - - @staticmethod - def sign_message(message: str, signing_key: str) -> str: - """ - Signs a message using the provided signing key. - - Args: - message (str): The message to sign. - signing_key (str): The base64-encoded signing key. - - Returns: - str: The signed, base64-encoded message. - """ - # Decode the signing key from base64 - signing_key_bytes = base64.b64decode(signing_key) - # Create a SigningKey object - nacl_signing_key = SigningKey(signing_key_bytes) - # Sign the message - signed_message = nacl_signing_key.sign(message.encode("utf-8")) - return base64.b64encode(signed_message).decode("utf-8") - - @staticmethod - def verify_message(signed_message: str, verify_key: str) -> str: - """ - Verifies a signed message using the provided verify key. - - Args: - signed_message (str): The signed, base64-encoded message. - verify_key (str): The base64-encoded verify key. - - Returns: - str: The original message if verification is successful. - - Raises: - ValueError: If the verification fails. - """ - # Decode the signed message and verify key from base64 - signed_message_bytes = base64.b64decode(signed_message) - verify_key_bytes = base64.b64decode(verify_key) - # Create a VerifyKey object - nacl_verify_key = VerifyKey(verify_key_bytes) - # Verify the signed message - try: - verified_message: bytes = nacl_verify_key.verify(signed_message_bytes) - return verified_message.decode("utf-8") - except Exception as e: - raise ValueError("Verification failed") from e - - @staticmethod - def encrypt_message(message: str, sender_private_key: str, recipient_public_key: str) -> str: - """ - Encrypts a message using the recipient's public key and the sender's private key. - - Args: - message (str): The message to encrypt. - sender_private_key (str): The sender's private key in base64 encoding. - recipient_public_key (str): The recipient's public key in base64 encoding. - - Returns: - str: The encrypted, base64-encoded message. - """ - # Decode the keys from base64 - sender_private_key_bytes = base64.b64decode(sender_private_key) - recipient_public_key_bytes = base64.b64decode(recipient_public_key) - # Create the Box for encryption - nacl_box = Box(PrivateKey(sender_private_key_bytes), PublicKey(recipient_public_key_bytes)) - # Encrypt the message - encrypted_message = nacl_box.encrypt(message.encode("utf-8")) - return base64.b64encode(encrypted_message).decode("utf-8") - - @staticmethod - def decrypt_message(encrypted_message: str, recipient_private_key: str, sender_public_key: str) -> str: - """ - Decrypts a message using the recipient's private key and the sender's public key. - - Args: - encrypted_message (str): The encrypted, base64-encoded message. - recipient_private_key (str): The recipient's private key in base64 encoding. - sender_public_key (str): The sender's public key in base64 encoding. - - Returns: - str: The decrypted message. - """ - # Decode the keys from base64 - recipient_private_key_bytes = base64.b64decode(recipient_private_key) - sender_public_key_bytes = base64.b64decode(sender_public_key) - # Create the Box for decryption - nacl_box = Box(PrivateKey(recipient_private_key_bytes), PublicKey(sender_public_key_bytes)) - # Decode the encrypted message from base64 - encrypted_message_bytes = base64.b64decode(encrypted_message) - # Decrypt the message - decrypted_message: bytes = nacl_box.decrypt(encrypted_message_bytes) - return decrypted_message.decode("utf-8") From 541f2bc796f6f4f4d64c3ce800c2c411a7ed9b17 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:09:03 +0200 Subject: [PATCH 46/56] Implement UserInput Modules in Frontend --- src/witty_wisterias/backend/backend.py | 13 +++- src/witty_wisterias/backend/database.py | 2 +- src/witty_wisterias/backend/message_format.py | 12 ++- .../backend/user_input_handler.py | 55 ++++++-------- src/witty_wisterias/frontend/app_config.py | 12 +++ .../frontend/components/chatapp.py | 7 +- .../frontend/components/create_chat.py | 26 +++++-- .../frontend/components/image_button.py | 4 +- .../frontend/components/sidebar.py | 1 + .../frontend/components/text_button.py | 47 ++++++++---- .../frontend/components/tos_accept_form.py | 2 +- src/witty_wisterias/frontend/frontend.py | 13 ---- .../frontend/states/chat_state.py | 75 +++++++++++++------ .../frontend/states/webcam_state.py | 55 ++++++++++++++ 14 files changed, 226 insertions(+), 98 deletions(-) create mode 100644 src/witty_wisterias/frontend/app_config.py create mode 100644 src/witty_wisterias/frontend/states/webcam_state.py diff --git a/src/witty_wisterias/backend/backend.py b/src/witty_wisterias/backend/backend.py index b997dfd7..e4295408 100644 --- a/src/witty_wisterias/backend/backend.py +++ b/src/witty_wisterias/backend/backend.py @@ -2,6 +2,9 @@ import json import zlib from dataclasses import asdict, dataclass, field +from io import BytesIO + +from PIL import Image from .cryptographer import Cryptographer from .database import Database @@ -236,7 +239,15 @@ def read_public_messages() -> list[MessageFormat]: verify_key = queried_data.verify_keys_stack[message.sender_id] try: # Verify the message content using the verify key - message.content = Cryptographer.verify_message(message.content, verify_key) + # Decode the image content if it's an image message + verified_content = Cryptographer.verify_message(message.content, verify_key) + + if message.event_type == EventType.PUBLIC_IMAGE: + image_data = base64.b64decode(verified_content) + message_content = Image.open(BytesIO(image_data)) + message.content = message_content.convert("RGB") + else: + message.content = verified_content verified_messaged.append(message) except ValueError: pass diff --git a/src/witty_wisterias/backend/database.py b/src/witty_wisterias/backend/database.py index 709eee18..a72d3869 100644 --- a/src/witty_wisterias/backend/database.py +++ b/src/witty_wisterias/backend/database.py @@ -20,7 +20,7 @@ UPLOAD_URL = HOSTER_URL + "/upload" JSON_URL = HOSTER_URL + "/json" # Search Term used to query for our images (and name our files) -FILE_SEARCH_TERM = "WittyWisteriasV7" +FILE_SEARCH_TERM = "WittyWisteriasV8" class Database: diff --git a/src/witty_wisterias/backend/message_format.py b/src/witty_wisterias/backend/message_format.py index 008d6d3b..3997994e 100644 --- a/src/witty_wisterias/backend/message_format.py +++ b/src/witty_wisterias/backend/message_format.py @@ -180,14 +180,22 @@ def from_message_format(message_format: MessageFormat, user_id: str) -> "Message Returns: Message: A Message object created from the MessageFormat. """ + is_image_message = message_format.event_type in (EventType.PUBLIC_IMAGE, EventType.PRIVATE_IMAGE) + if is_image_message: + # Decode the base64 image data to an Image object + image_data = base64.b64decode(message_format.content) + message_content = Image.open(BytesIO(image_data)) + message_content = message_content.convert("RGB") + else: + message_content = message_format.content return MessageState( - message=message_format.content, + message=message_content, user_id=message_format.sender_id, receiver_id=message_format.receiver_id if message_format.receiver_id != "None" else None, user_name=message_format.extra_event_info.user_name or message_format.sender_id, user_profile_image=message_format.extra_event_info.user_image, own_message=user_id == message_format.sender_id, - is_image_message=message_format.event_type in (EventType.PUBLIC_IMAGE, EventType.PRIVATE_IMAGE), + is_image_message=is_image_message, timestamp=message_format.timestamp, ) diff --git a/src/witty_wisterias/backend/user_input_handler.py b/src/witty_wisterias/backend/user_input_handler.py index 8057aad4..c9d13655 100644 --- a/src/witty_wisterias/backend/user_input_handler.py +++ b/src/witty_wisterias/backend/user_input_handler.py @@ -1,10 +1,8 @@ import base64 +import json import httpx -import regex as re -from bs4 import BeautifulSoup - -from .exceptions import InvalidResponseError +from websockets.sync.client import connect # Global HTTP Session for the User Input Handler HTTP_SESSION = httpx.Client(timeout=30) @@ -19,7 +17,7 @@ class UserInputHandler: @staticmethod def image_to_text(image_base64: str) -> str: """ - Converts a base64 encoded image to text using https://freeocr.ai/. + Converts a base64 encoded image to text using https://olmocr.allenai.org. Args: image_base64 (str): A base64-encoded string representing the image. @@ -27,36 +25,25 @@ def image_to_text(image_base64: str) -> str: Returns: str: The text extracted from the image. """ - # Getting some Cookies etc. - page_resp = HTTP_SESSION.get("https://freeocr.ai/") - # Getting All JS Scripts from the Page - soup = BeautifulSoup(page_resp.text, "html.parser") - js_script_links = [script.get("src") for script in soup.find_all("script") if script.get("src")] - # Getting Page Script Content - page_js_script: str | None = next((src for src in js_script_links if "page-" in src), None) - if not page_js_script: - raise InvalidResponseError("Could not find the page script in the response.") - page_script_content = HTTP_SESSION.get("https://freeocr.ai" + page_js_script).text - # Getting the Next-Action by searching for a 42 character long hex string - next_action_search = re.search(r"[a-f0-9]{42}", page_script_content) - if not next_action_search: - raise InvalidResponseError("Could not find Next-Action in the response.") - next_action = next_action_search.group(0) + # Connecting to the WebSocket OCR server + with connect("wss://olmocr.allenai.org/api/ws", max_size=10 * 1024 * 1024) as websocket: + # Removing the "data:image/jpeg;base64," prefix if it exists + image_base64 = image_base64.removeprefix("data:image/jpeg;base64,") + # Sending the base64 image to the WebSocket server + websocket.send(json.dumps({"fileChunk": image_base64})) + websocket.send(json.dumps({"endOfFile": True})) + + # Receiving the response from the server + while True: + response_str = websocket.recv() + response_json = json.loads(response_str) - # Posting to the OCR service - resp = HTTP_SESSION.post( - "https://freeocr.ai/", - json=["data:image/jpeg;base64," + image_base64], - headers={ - "Next-Action": next_action, - "Next-Router-State-Tree": "%5B%22%22%2C%7B%22children%22%3A%5B%5B%22locale%22%2C%22de%22%2" - "C%22d%22%5D%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C" - "%22%2Fde%22%2C%22refresh%22%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%5D", - }, - ) - # Removing Content Headers to extract the text - extracted_text: str = resp.text.splitlines()[1][3:-1] - return extracted_text + # Check if the response contains the final processed data + if response_json.get("type") == "page_complete": + # Getting the Response data + page_data = response_json.get("data", {}).get("response", {}) + # Returning the extracted Text + return page_data.get("natural_text", "No text found.") @staticmethod def text_to_image(text: str) -> str: diff --git a/src/witty_wisterias/frontend/app_config.py b/src/witty_wisterias/frontend/app_config.py new file mode 100644 index 00000000..a0828d1e --- /dev/null +++ b/src/witty_wisterias/frontend/app_config.py @@ -0,0 +1,12 @@ +import reflex as rx + +app = rx.App( + theme=rx.theme(appearance="light", has_background=True, radius="large", accent_color="teal"), + stylesheets=[ + "https://fonts.googleapis.com/css2?family=Bitcount+Prop+Single:slnt,wght@-8,600&display=swapp", + ], + style={ + "font_family": "Bitcount Prop Single", + "background_color": "white", + }, +) diff --git a/src/witty_wisterias/frontend/components/chatapp.py b/src/witty_wisterias/frontend/components/chatapp.py index fdb2c104..d686cd5f 100644 --- a/src/witty_wisterias/frontend/components/chatapp.py +++ b/src/witty_wisterias/frontend/components/chatapp.py @@ -76,7 +76,12 @@ def chat_app() -> rx.Component: class_name="flex flex-col gap-4 pb-6 pt-3 h-full w-full bg-gray-50 p-5 rounded-xl shadow-sm", ), rx.divider(), - rx.hstack(send_text_component(), send_image_component(), class_name="mt-auto mb-3 w-full"), + rx.hstack( + rx.box(send_text_component(), width="50%"), + rx.box(send_image_component(), width="50%"), + spacing="2", + class_name="mt-auto mb-3 w-full", + ), spacing="4", class_name="h-screen w-full mx-5", ) diff --git a/src/witty_wisterias/frontend/components/create_chat.py b/src/witty_wisterias/frontend/components/create_chat.py index e960e4a2..e6b9e27b 100644 --- a/src/witty_wisterias/frontend/components/create_chat.py +++ b/src/witty_wisterias/frontend/components/create_chat.py @@ -31,14 +31,24 @@ def create_chat_component(create_chat_button: rx.Component, user_id: str | None variant="surface", class_name="w-full", ), - rx.text_area( - placeholder="Write your first message...", - size="3", - rows="5", - name="message", - required=True, - variant="surface", - class_name="w-full", + rx.cond( + ChatState.frame_data, + rx.image( + src=ChatState.frame_data, + width="480px", + alt="Live frame", + border="2px solid teal", + border_radius="16px", + ), + rx.hstack( + rx.spinner(size="3"), + rx.text( + "Loading Webcam image...", + color_scheme="gray", + size="5", + ), + align="center", + ), ), rx.hstack( rx.dialog.close(rx.button("Cancel", variant="soft", color_scheme="gray")), diff --git a/src/witty_wisterias/frontend/components/image_button.py b/src/witty_wisterias/frontend/components/image_button.py index 6819812a..9664e376 100644 --- a/src/witty_wisterias/frontend/components/image_button.py +++ b/src/witty_wisterias/frontend/components/image_button.py @@ -43,13 +43,13 @@ def send_image_component() -> rx.Component: rx.center(rx.text("Send Image")), padding="24px", radius="large", - flex=1, + width="100%", ), ), rx.dialog.content( rx.dialog.title("Send Image"), rx.dialog.description( - "Send an image by describing it in the box below. TEMP: You can post an image URL.", + "Send an image by describing it in the box below. It will be generated using AI and sent to the chat.", size="2", margin_bottom="16px", ), diff --git a/src/witty_wisterias/frontend/components/sidebar.py b/src/witty_wisterias/frontend/components/sidebar.py index e71cb27a..e02a29f6 100644 --- a/src/witty_wisterias/frontend/components/sidebar.py +++ b/src/witty_wisterias/frontend/components/sidebar.py @@ -41,6 +41,7 @@ def chat_sidebar() -> rx.Component: rx.button( rx.icon("circle-plus", size=16, class_name="text-gray-500"), class_name="bg-white", + on_click=ChatState.start_webcam, ) ), spacing="2", diff --git a/src/witty_wisterias/frontend/components/text_button.py b/src/witty_wisterias/frontend/components/text_button.py index 511e03da..c71d7014 100644 --- a/src/witty_wisterias/frontend/components/text_button.py +++ b/src/witty_wisterias/frontend/components/text_button.py @@ -11,22 +11,35 @@ def text_form() -> rx.Component: rx.Component: The Text form component. """ return rx.vstack( - rx.text_area( - placeholder="Write your text here...", - size="3", - rows="5", - name="message", - required=True, - variant="surface", - class_name="w-full", + rx.cond( + ChatState.frame_data, + rx.image( + src=ChatState.frame_data, + width="480px", + alt="Live frame", + border="2px solid teal", + border_radius="16px", + ), + rx.hstack( + rx.spinner(size="3"), + rx.text( + "Loading Webcam image...", + color_scheme="gray", + size="5", + ), + align="center", + ), ), rx.hstack( - rx.dialog.close(rx.button("Cancel", variant="soft", color_scheme="gray")), + rx.dialog.close( + rx.button( + "Cancel", variant="soft", color_scheme="gray", type="reset", on_click=ChatState.disable_webcam + ) + ), rx.dialog.close(rx.button("Send", type="submit")), ), spacing="3", margin_top="16px", - justify="end", ) @@ -37,20 +50,26 @@ def send_text_component() -> rx.Component: Returns: rx.Component: The Text Button Component, which triggers the Text Message Form. """ - # TODO: This should be replaced with the Webcam handler, text will do for now return rx.dialog.root( rx.dialog.trigger( rx.button( rx.center(rx.text("Send Text")), padding="24px", radius="large", - flex=1, + on_click=ChatState.start_webcam, + width="100%", ), ), rx.dialog.content( - rx.dialog.title("Send Text (TEMP)"), + rx.dialog.title("Send Text"), + rx.dialog.description( + "Send a text message to the chat by writing your message on a physical peace of paper and taking a" + "picture of it with your webcam.", + size="2", + margin_bottom="16px", + ), rx.dialog.description( - "Send a text message to the chat. This is a temp feature until the webcam handler is implemented.", + "Your Webcam image is private and will not be shared in the chat.", size="2", margin_bottom="16px", ), diff --git a/src/witty_wisterias/frontend/components/tos_accept_form.py b/src/witty_wisterias/frontend/components/tos_accept_form.py index 3417be0f..217f410e 100644 --- a/src/witty_wisterias/frontend/components/tos_accept_form.py +++ b/src/witty_wisterias/frontend/components/tos_accept_form.py @@ -14,7 +14,7 @@ def tos_accept_form() -> rx.Component: rx.vstack( rx.text("You hereby accept the Terms of Service of:"), rx.hstack( - rx.link("freeocr.ai", href="https://freeocr.ai/terms-of-service"), + rx.link("allenai.org", href="https://allenai.org/terms"), rx.link("pollinations.ai", href="https://pollinations.ai/terms"), rx.link("freeimghost.net", href="https://freeimghost.net/page/tos"), align="center", diff --git a/src/witty_wisterias/frontend/frontend.py b/src/witty_wisterias/frontend/frontend.py index 33f4f9bb..b99d38f7 100644 --- a/src/witty_wisterias/frontend/frontend.py +++ b/src/witty_wisterias/frontend/frontend.py @@ -19,16 +19,3 @@ def index() -> rx.Component: class_name="overflow-hidden h-screen w-full", ), ) - - -app = rx.App( - theme=rx.theme(appearance="light", has_background=True, radius="large", accent_color="teal"), - stylesheets=[ - "https://fonts.googleapis.com/css2?family=Bitcount+Prop+Single:slnt,wght@-8,600&display=swapp", - ], - style={ - "font_family": "Bitcount Prop Single", - "background_color": "white", - }, -) -app.add_page(index) diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index 2246abb4..36ab7ff6 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -1,4 +1,5 @@ import asyncio +import base64 import io import json import threading @@ -7,16 +8,17 @@ from typing import Literal, cast import reflex as rx -import requests from backend.backend import Backend from backend.cryptographer import Cryptographer from backend.message_format import EventType, MessageFormat, MessageState +from backend.user_input_handler import UserInputHandler from PIL import Image from frontend.states.progress_state import ProgressState +from frontend.states.webcam_state import WebcamStateMixin -class ChatState(rx.State): +class ChatState(WebcamStateMixin, rx.State): """The Chat app state, used to handle Messages. Main Frontend Entrypoint.""" # Tos Accepted (Note: We need to use a string here because LocalStorage does not support booleans) @@ -129,14 +131,29 @@ def select_chat(self, chat_name: str) -> Generator[None, None]: yield @rx.event - def send_public_text(self, form_data: dict[str, str]) -> Generator[None, None]: + def start_webcam(self, _: dict[str, str]) -> None: + """ + Start the webcam capture loop. + + Args: + _ (dict[str, str]): The form data containing the message in the `message` field. Unused. + """ + self.recording = True + yield ChatState.capture_loop + + @rx.event + async def send_public_text(self, _: dict[str, str]) -> Generator[None, None]: """ Reflex Event when a text message is sent. Args: - form_data (dict[str, str]): The form data containing the message in the `message` field. + _ (dict[str, str]): The form data containing the message in the `message` field. Unused. """ - message = form_data.get("message", "").strip() + # Stop Webcam Stream + ChatState.disable_webcam() + # Converting last Webcam Frame to Text + message = UserInputHandler.image_to_text(str(self.frame_data)) + if message: # Sending Placebo Progress Bar yield ProgressState.public_message_progress @@ -148,6 +165,7 @@ def send_public_text(self, form_data: dict[str, str]) -> Generator[None, None]: message=message, user_id=self.user_id, user_name=self.user_name, + receiver_id=None, user_profile_image=self.user_profile_image, own_message=True, is_image_message=False, @@ -184,9 +202,12 @@ def send_public_image(self, form_data: dict[str, str]) -> Generator[None, None]: """ message = form_data.get("message", "").strip() if message: - # Temporary - response = requests.get(message, timeout=10) - img = Image.open(io.BytesIO(response.content)) + # Converting the Image Description to an Image + base64_image = UserInputHandler.text_to_image(message) + # Decode the Base64 string to bytes + image_data = base64.b64decode(base64_image) + # Open the image stream with PIL + pil_image = Image.open(io.BytesIO(image_data)) # Sending Placebo Progress Bar yield ProgressState.public_message_progress @@ -195,9 +216,10 @@ def send_public_image(self, form_data: dict[str, str]) -> Generator[None, None]: # Appending new own message to show in the Chat self.messages.append( MessageState( - message=img, + message=pil_image, user_id=self.user_id, user_name=self.user_name, + receiver_id=None, user_profile_image=self.user_profile_image, own_message=True, is_image_message=True, @@ -210,7 +232,7 @@ def send_public_image(self, form_data: dict[str, str]) -> Generator[None, None]: message_format = MessageFormat( sender_id=self.user_id, event_type=EventType.PUBLIC_IMAGE, - content=message, + content=base64_image, timestamp=message_timestamp, signing_key=self.signing_key, verify_key=self.get_key_storage("verify_keys")[self.user_id], @@ -232,7 +254,11 @@ def send_private_text(self, form_data: dict[str, str]) -> Generator[None, None]: Args: form_data (dict[str, str]): The form data containing the message in the `message` field. """ - message = form_data.get("message", "").strip() + # Stop Webcam Stream + ChatState.disable_webcam() + # Converting last Webcam Frame to Text + message = UserInputHandler.image_to_text(str(self.frame_data)) + receiver_id = form_data.get("receiver_id", "").strip() or self.selected_chat if message and receiver_id: if receiver_id not in self.get_key_storage("public_keys"): @@ -308,9 +334,12 @@ def send_private_image(self, form_data: dict[str, str]) -> Generator[None, None] self.selected_chat = receiver_id yield - # Temporary - response = requests.get(message, timeout=10) - img = Image.open(io.BytesIO(response.content)) + # Converting the Image Description to an Image + base64_image = UserInputHandler.text_to_image(message) + # Decode the Base64 string to bytes + image_data = base64.b64decode(base64_image) + # Open the image stream with PIL + pil_image = Image.open(io.BytesIO(image_data)) # Sending Placebo Progress Bar yield ProgressState.private_message_progress @@ -318,7 +347,7 @@ def send_private_image(self, form_data: dict[str, str]) -> Generator[None, None] message_timestamp = datetime.now(UTC).timestamp() # Appending new own message to show in the Chat chat_message = MessageState( - message=img, + message=pil_image, user_id=self.user_id, user_name=self.user_name, receiver_id=receiver_id, @@ -340,7 +369,7 @@ def send_private_image(self, form_data: dict[str, str]) -> Generator[None, None] sender_id=self.user_id, receiver_id=receiver_id, event_type=EventType.PRIVATE_IMAGE, - content=message, + content=base64_image, timestamp=message_timestamp, own_public_key=self.get_key_storage("public_keys")[self.user_id], receiver_public_key=self.get_key_storage("public_keys")[receiver_id], @@ -359,9 +388,14 @@ def send_private_image(self, form_data: dict[str, str]) -> Generator[None, None] async def check_messages(self) -> None: """Reflex Background Check for new messages.""" while True: + # To not block the UI thread, we run this in an executor before the async with self. + loop = asyncio.get_running_loop() + verify_keys, public_keys = await loop.run_in_executor(None, Backend.read_public_keys) + public_messages = await loop.run_in_executor(None, Backend.read_public_messages) + backend_private_message_formats = await loop.run_in_executor( + None, Backend.read_private_messages, self.user_id, self.private_key + ) async with self: - # Read Verify and Public Keys from Backend - verify_keys, public_keys = Backend.read_public_keys() # Push Verify and Public Keys to the LocalStorage for user_id, verify_key in verify_keys.items(): self.add_key_storage("verify_keys", user_id, verify_key) @@ -369,10 +403,10 @@ async def check_messages(self) -> None: self.add_key_storage("public_keys", user_id, public_key) # Public Chat Messages - for message in Backend.read_public_messages(): + for message in public_messages: # Check if the message is already in the chat using timestamp message_exists = any( - all_messages.timestamp == message.timestamp and all_messages.timestamp == message.sender_id + all_messages.timestamp == message.timestamp and all_messages.user_id == message.sender_id for all_messages in self.messages ) @@ -393,7 +427,6 @@ async def check_messages(self) -> None: ) # Private Chat Messages stored in the Backend - backend_private_message_formats = Backend.read_private_messages(self.user_id, self.private_key) backend_private_messages = [ MessageState.from_message_format(message_format, str(self.user_id)) for message_format in backend_private_message_formats diff --git a/src/witty_wisterias/frontend/states/webcam_state.py b/src/witty_wisterias/frontend/states/webcam_state.py new file mode 100644 index 00000000..adb78f82 --- /dev/null +++ b/src/witty_wisterias/frontend/states/webcam_state.py @@ -0,0 +1,55 @@ +import asyncio +import base64 + +import cv2 +import reflex as rx +from reflex.utils.console import LogLevel, set_log_level + +from frontend.app_config import app + +# Filer race condition warnings, which can occur when the websocket is disconnected +# This is not an issue as the WebcamStateMixin is designed to handle disconnections gracefully +# Note: Reflex Console is very bad so our only option is to raise the log level to ERROR +set_log_level(LogLevel.ERROR) + +# Opening/PreLoading Webcam Video Capture for faster Load times +webcam_cap = cv2.VideoCapture(0) +webcam_cap.set(cv2.CAP_PROP_FRAME_WIDTH, 480) +webcam_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 320) +webcam_cap.set(cv2.CAP_PROP_FPS, 60) +webcam_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + + +class WebcamStateMixin(rx.State, mixin=True): + """Mixin for managing webcam state in the application.""" + + frame_data: str | None = None + recording: bool = False + + @rx.event + def disable_webcam(self) -> None: + """Stop the webcam capture loop.""" + self.recording = False + + @rx.event(background=True) + async def capture_loop(self) -> None: + """Continuously capture frames from the webcam and update the frame data.""" + if not webcam_cap or not webcam_cap.isOpened(): + raise RuntimeError("Cannot open webcam at index 0") + + # While should record and Tab is open + while self.recording and self.router.session.client_token in app.event_namespace.token_to_sid: + ok, frame = webcam_cap.read() + if not ok: + await asyncio.sleep(0.1) + continue + + # Taking a 480p grayscale frame for better performance + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # encode JPEG with lower quality + _, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 20]) + data_url = "data:image/jpeg;base64," + base64.b64encode(buf).decode() + + async with self: + self.frame_data = data_url From 26451d6ab1930ddb747068448b317f25c70be591 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:21:19 +0200 Subject: [PATCH 47/56] Fix Missing Import and Reflex Warnings --- src/witty_wisterias/frontend/frontend.py | 1 + .../frontend/states/chat_state.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/witty_wisterias/frontend/frontend.py b/src/witty_wisterias/frontend/frontend.py index b99d38f7..61e9ef78 100644 --- a/src/witty_wisterias/frontend/frontend.py +++ b/src/witty_wisterias/frontend/frontend.py @@ -1,5 +1,6 @@ import reflex as rx +from frontend.app_config import app # noqa: F401 from frontend.components.chatapp import chat_app from frontend.components.sidebar import chat_sidebar from frontend.components.tos_accept_form import tos_accept_form diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/src/witty_wisterias/frontend/states/chat_state.py index 36ab7ff6..c5be640a 100644 --- a/src/witty_wisterias/frontend/states/chat_state.py +++ b/src/witty_wisterias/frontend/states/chat_state.py @@ -5,7 +5,7 @@ import threading from collections.abc import AsyncGenerator, Generator from datetime import UTC, datetime -from typing import Literal, cast +from typing import Any, Literal, cast import reflex as rx from backend.backend import Backend @@ -35,7 +35,7 @@ class ChatState(WebcamStateMixin, rx.State): # Own User Data user_id: str = rx.LocalStorage("", name="user_id", sync=True) user_name: str = rx.LocalStorage("", name="user_name", sync=True) - user_profile_image: str | None = rx.LocalStorage(None, name="user_profile_image", sync=True) + user_profile_image: str = rx.LocalStorage("", name="user_profile_image", sync=True) # Own Signing key and Others Verify Keys for Global Chat signing_key: str = rx.LocalStorage("", name="signing_key", sync=True) verify_keys_storage: str = rx.LocalStorage("{}", name="verify_keys_storage", sync=True) @@ -107,7 +107,7 @@ def accept_tos(self) -> Generator[None, None]: yield @rx.event - def edit_user_info(self, form_data: dict[str, str]) -> Generator[None, None]: + def edit_user_info(self, form_data: dict[str, Any]) -> Generator[None, None]: """ Reflex Event when the user information is edited. @@ -116,7 +116,7 @@ def edit_user_info(self, form_data: dict[str, str]) -> Generator[None, None]: """ self.user_name = form_data.get("user_name", "").strip() # A User Profile Image is not required in the Form. - self.user_profile_image = form_data.get("user_profile_image", "").strip() or None + self.user_profile_image = form_data.get("user_profile_image", "").strip() yield @rx.event @@ -142,7 +142,7 @@ def start_webcam(self, _: dict[str, str]) -> None: yield ChatState.capture_loop @rx.event - async def send_public_text(self, _: dict[str, str]) -> Generator[None, None]: + async def send_public_text(self, _: dict[str, Any]) -> Generator[None, None]: """ Reflex Event when a text message is sent. @@ -193,7 +193,7 @@ async def send_public_text(self, _: dict[str, str]) -> Generator[None, None]: threading.Thread(target=Backend.send_public_message, args=(message_format,), daemon=True).start() @rx.event - def send_public_image(self, form_data: dict[str, str]) -> Generator[None, None]: + def send_public_image(self, form_data: dict[str, Any]) -> Generator[None, None]: """ Reflex Event when an image message is sent. @@ -247,7 +247,7 @@ def send_public_image(self, form_data: dict[str, str]) -> Generator[None, None]: threading.Thread(target=Backend.send_public_message, args=(message_format,), daemon=True).start() @rx.event - def send_private_text(self, form_data: dict[str, str]) -> Generator[None, None]: + def send_private_text(self, form_data: dict[str, Any]) -> Generator[None, None]: """ Reflex Event when a private text message is sent. @@ -315,7 +315,7 @@ def send_private_text(self, form_data: dict[str, str]) -> Generator[None, None]: threading.Thread(target=Backend.send_private_message, args=(message_format,), daemon=True).start() @rx.event - def send_private_image(self, form_data: dict[str, str]) -> Generator[None, None]: + def send_private_image(self, form_data: dict[str, Any]) -> Generator[None, None]: """ Reflex Event when a private image message is sent. From af1cbe844fe23ad96425f60f1a6613c44ec737d9 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:38:01 +0200 Subject: [PATCH 48/56] Rename to "ShitChat" --- src/witty_wisterias/assets/favicon.ico | Bin 4286 -> 11121 bytes src/witty_wisterias/backend/database.py | 2 +- .../frontend/components/sidebar.py | 5 +++-- src/witty_wisterias/frontend/frontend.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/witty_wisterias/assets/favicon.ico b/src/witty_wisterias/assets/favicon.ico index 166ae995eaa63fc96771410a758282dc30e925cf..f8ff40ba2cb91e014b80c211b3fb1b9d88636267 100644 GIT binary patch literal 11121 zcmXYX2Rv2(|Npty#l1!@HzUL~x>mBshj7WxETv=`x^Iue1HFYANO(ZeVqF^=k6KUc8E(z z`&&o(qM)$XMI5x+*JjqVu3i5Q;=(j#wH?elz3g9)?_;Gj>}4kfTuqt*c2?FFBr{Aa zMp&N(#%hK|A|Elx^!A2+p_TgX_8NC?5FOdN!}(XELGFIG%{lJkRi<4(20QGly2d0Dflh}* zaWq1}a$&wPiKmUIP4Sp6V3`oFkYGI86&}_hQU%f^NMWevKDQXQH?a97A8oh8{FiZrzY!HbZ+EZEqL=;(X>3dO)%P{ z+M_wt6Dj+>EWdOIr3?#VEaRx*A}cHJcrlbcgIhg<2Zprn)t((T=vKW>I>VUYmi~02 z!Rzxc`JkGxb=pRO?GuT@xrSVsm*>iQ-$}&s#ojlMqVCi6-A@JSMHF zJ7ClI`#xBG-OP~eoYHiov6X#6a;h5p8nC9dc3wlsU&M~@kGe35 z;v>uZI|o-M%Uiuj#-ol-jz(<~4s+XqF4d_kaM{>al-ipvrigcG8S1a2 z0ou`?9(yI--TW2i?UVC;UbqmKQtuKj7DbrjRRJ#PALs83_$tc19ym7N9El4T->aPG zQPx65|JCTm7qm~VZI81p(3O`4O(n|!8`;!>hIp-3a3neV#?(BBTuEc@5)7$jhS zlRTx7eZjlMNx)zWrIbkIctAHHNF(6*9^b{5I2wu{@k2$qBmAoaDwk{h)KWwMl+3(oIbGI zDj5){bHdi57_|QxHGIS2eGlL@L`B&LBqmzvE@R@R*N-W4$xRuUB*(@f!r-A9?msrR z(Aqpjm_WBJvC*omNyS>MU>^q^b_G1)*nfNm*QJzFlfjou%m4m0vy5+)&D3QS@9j6r za6D*5p~sgO7OGgJEu~PlqUsM%>Y^`R4r1j^*k{m|mW9t2=xFCAi#}$OWaGsC@Jgps zam>ta&>fO}_2cVD()w*1F=V_!ez2(;bGve9Gc{o2NYiw3PyGu0KG&2l`JM5fPK{e8 zIl90(=aD{(lR)DiQ<*<>g}KFK;feKZtY2&_=_P&Bd2^;?zdoH?_X}|hTue=B(J`u& zOlpseElrSE-J<96pFVpOO%}PYwmT4e@EEs?jmujZ)8hm3EiFWF01hnu;Guy84*t{q zsQ`=GqX+z<+#rzi~%qpFTeN2Jybz+?p|;3;cgFnn@ngdBv~m% zWqWXfHm{uFws70`IfL);&x@@5;7EY)#FKFnqx(0#{9;)C^{MDCL_NZmmU&2>W)AzI z4M2tL{!pfhH^?pM*v*Sapz{Dpm?TU#9Ai4qltYlIQPqticgUTrecL*NoYZ&R`}ixy zbb2~39Y_Q0wZAm}!24X{xJIa9TCAaHoReOpHSv3t&3}1OZjE;yB{&6poCz#t**+yu zJhqq$E;QBUu29_#EaeAJL_MpDhb{-X-%Ch?PPIe=+$B+FvwUVgJfemG>p=ias_D@B z)O$p@u|!7CyXA4nSjQq1^K z0xY+--b)uV#NJ^kDzU<&Z87F#pZ9*1EFd3`ZWo-Y`$J-)(vF?7aQ=&UE{w ztm|5C!CV;pBW92pQyNu~e}yVdeF)?KwCl}ftQ{>pf+xB+S>)!sJC#NTX_D$MLN?o{ z)19-$xm@~@2-oY_8-0b%g+vxaDY$N$LYwr4`85v`n0=dlAx!P?-{+QBQ9PoTswUyT zA$lu^mWT#-}VcyZR!ZqlaH9#qPTsd6&G?I zHK$2AG(Qu+Qzn##%27w*sqE@`HBA?V3azIF(V(*5*5+(S%g{%M*=VMQ!G)2^lKqpt zItdF4iy=g5h}>OA1iGJB21e2W_IbEN3(fR9v(;?kxc|FGcQh5nYmJ9vLAR2cikvsf z7_g?Ja5)lqdfC`r8Ma_bod1HttpDBYvjt1&;X-vYt(JgqLOljn?v=2wN^#;FEoodX{lB9sSN)uL_2Bk-J;L z%=K@dTZ^8LG!-|$3wN=FPxjk3V1Q2tmpA^hNZB_>e-fzWQbN1*<{26QD1J~<4@6AOT(J40*h~0z-0e404@k1)$9+6 z-isHe(JX|xlT;RPm(_Nl-V2MyfZ~Wr(p4Zekb5>DtMQXnL_u~VTat?H6SnQ~t&{Vj zL(b&5bFU|oj=w}ycc$2gyzu#cu7{yswP{zs^}KHCHa@qMXk#j9D{!+SW{xIZpr&Ja z;Tik=SpHWmBQ0nnI+TXZQc;q=X znAf!MJGW)H+)a$_8V(qMCO5oRJ+fv^3y5WxV7n4^=6yO)#)NNWz^ZUeqePj_AOeCY z2GLiU_$+d3Hh*O|2Zhf>L^D<$%w@I= zyuU-Qy_6$+dSU3&bSR*)?)c}*^wCyV7yZ&JIfG-?N4V#?Pn7f{Q4STa%$^R}r$=4Q z0D8I_aCm-X&RM=@A5f2oKYFwB;7VOx(2lFmbc_%UZePh%R|K2ceLi56&pLQTZjsA- zWSyEmrq6BV%6bpSlFCX@&t4b8_8iQ`uF;uE?WmMHz1%xe{3s{`9iu5_7}#s55cBrx z^T&fWM3rovZY#U*D~hMB3^j8Y(8*AzFdpdeGfXX&1z4g1f{M^$XVhuhB;#hPDJO?_ zEjFOTO@r@X7z%L3$4E`9y?sUDfb!qR<42SeV|;d%Jx#eevLscg#8ynXxA z3(_X-+tiYBYwvPdTt(^IUR2rpq~srq1ykQKyZ|Ur3p?B0A+cg zi$*6BoAq$m?tiN#oQ1eX4Yxb|@OAZv&TK?EN5M5A+qWI@#%jm+|9hKmv(Su4ya->% zFt#Dkqg1w$eX3iXGWKnWe6@LN?iT=V%I7ZCJN~b4o#F?gCIc4yw_}Cc4MyV|$|wX- ze@@Z)7x&cPPX@rdQy?-Ek`aW14x49;6XofE_dkeYFx9`f4}qupz)lF2doXa`0mi7i z$3)z}qACP3;_z<~VS6UcW?cwDarDK-FpZBo5Yw1lfvZ@svkh5>?(`2R!o)5Bic)LU z^DjdzB>z87e)vqOm7Z7&(HOEo#2CFfdrL%E4oJ`t{6DaLp!f;(x_fy&Z&wt3Ps2?M zO9NoG>b=D!wHBqn!v1fHqt@Nv;3QW3xe=PK+@f`KgpK1(0=YW6kE_xll(^`O(54B~f{MsE6qbf2fIjqN*>DS? zI0jx#X1`=9{5_saD1XGj1KU|n)gld_;xK93hIo&y(Ci00Ebu48DOUPQXl#*R9)^|| zm7p5D#)0j&^QE>8aFLL-2g?(6#DcIZ7W;PdxsB`5%mR+LKSH?HbRVfO3_AY!;#|Fa zo)pZG&Qm-;1y>JQQWJ9o=!WFOf9HMov`OP!^Wl{&O1m+E|3md0XEh@&=jN(F?+30y ztAMN{hKYuX@69oomI!uh;VV&1{D9wIUPbR5#e=`eEq@@VbR9h2Z&hP6xAMQiGt-*D zZf^bXZbPtQU3;=nhr2owZSnQN`#whlnfx42ehj`0VN#?hjKLqwbVvmHe6cIq5A6Qf z9MJPd&o}FGUc2OD4ga-~N-qiS``zj_wuHGIu76^pQ8_2s_N_JyiVx6jAdE^dEawA& zRL?w9_2J70yz29u8ME@VXlPZjL5%gf;f1_Tak~ZEGLU65jL|_ku)h&%cQ|^VFzXke7y$jw*v%rX0!$osnz=z^Y=`E5xr1)Io%X2UH*tC3{q&s) z@=d~BYqNXbmXiSeSuV}LSm_J%gW9M4sj1wREdn5c!P&z6m!bvJO$d|ou&S#GUtxxT zo6yl51}dyt+GLFN(3=?~puK7joY?lGpCtrd0a4UZ@0Dw9WaRvdspjevL>u48K8$ zgZy*G0HeaFiHUanX;-|Fw))d}w>Dj4GUGDTwbBZ_^!ImS!#eNv`o_LZGzMmS9Dhii z+U}fzrTQH_FeaF|Aqa98zDiKpO^Z8m2>W0}B|N*cM^;7R*||mXA$3IP0=IF1F(VN3 zUlKZ*S}CIdj|0;TfP8gBptbaP3<3ICR=B6j!U8!Zt6J3F>cSD=QH(ph84pOyCBsHA zJP6hT)3P#FSd4gX(Rhc2M&*O`Vw+=qN55TV*o={IBLdw_eTnaApS!R$>D+T_Sx(!# zliwb0u`ts@@MTAsSWtnYkIlv}zW5CsuqLT1CWE?A2{c&1GV@?-z9T7yS~etPEqDT> z9IzVqroy%f0}lR51A=CD25p{KpZsL`BTq(%GJcC0*;;#o={5yFz2k)B7C(xxeYUMZ z_PU(Y=EF3UhLOC7$T0_&{)9;o4?3pH2d$S2i%+`{Ed*PDrv-ADel&MRG+v4QMlk>y z?g6?L(szHPgmXSVU6YTQKp;obTN=Dkz4{}X+nG$*7(|X7ka8YMC!v*l6V9rq^eTji z$+SHp17-4{t32{r!$L{_&0~N))+6B>b_R-~CrsHhO4H;l??#mG&xpg@?q%!;}FWWy7Y`4dJJH$VI`ean+6b`jZJts?dEszKVA&O6v)F=6z0h%Gi z`W_KYh&w_a^!q_bk-~uSv7xW|L3_88=fo%E^De0_iB59biL$=x*DZgN`mCTDxVEyx;B=eHma!fV$0u~w zcU|6!yMHU5OTp-c?++1a9Y9-7rk_CP`j za7HhL^TG2x z(&)jyuMfJpz?&t5wp|uEH2dD-Y&^mnY;rK=!%q{NcJX|W>+aGSArD0ckw0f2U(9A6 zinIWCG=Uafb!F5?VdRCT^2N7n^rtN$l*k$f1j9fT<`4CN0sit2aE*1=?8Pyye?t;adWwkm!r~ZC-c@G=88*)56SH z_8o_h&?kqhUyZo~Mtv+#M))=GOv#f6^VwY!VyBUS|282j{Us-3!_uIvcGJ>r=hv_LIQ}=H)&n>ZkN0HT(<)tbUz}%0a;X`u?{uGI1hb=UI0a zRhpxH6`0j1k%M9F{bq}9j9pl{?`wXle`>u%EckWxOR@PkVoh_OP%7|vjHzV8_G9;S zD|_t4v5vB?wt8)8*!s6Y1Hgxtj4%Y|{<9_?@AN#om=BX=)~p;VczWE#tQfFrnY8@w zCE#vz(Y)~JO9R5ho)dkcE|wWc29FWoHG8JZ>)^t_e`n99mmyBqL;@R)xK~ib2V_2@ zRgi{ZcQ`lc=TcCKPK*#WbhVZ2MR0(=XX;$t&2XU0E)(r)Zi1@lni#1rqY#e+ec{)2 z5Vn^nEd@S%V*wqhL;x8WgLgZg zwMP}0@iF0mV3h6Owj}9T@0-R{77Me^@X|zJyH?(7{GuO_#)QLwRtS@eIzUBI;W;_# zEoy5B&TwIco%vq}3F$!1>S*NVxB>9C%lOdR_KP7DW`t6MaX(cd=ZRgKO$$PlJOd2s z8E%!iByq^PnDiq|mZ`>&q9KWc`k8LJV--+xO%ngi*-b8}xz-iL8aoxlj5q0Z@|~xd z76fWiDsQrM(}$bPeKqr+!19LRz~K+cwZ<5nL86{zGEcj?Xl5z~?4;2LJo$B5U@1M| zYR#T?x@(c;I1vXZBPXGFzGQ^q0b^Y0VThzzQE9lacTtNY9C4Qh5Z(y=t;K$jG3X4B zHr_8UnTzD|Or*99Z$r5-!5q_pqUx~nrywq6w&Qs$wEu9RPiIj$sAQnAPG`3HIghWP zk-wbotN8|NNtb`d12c|sC}|o`4JctjeQk<<_JIisODAMEgs^2pXjvX4@?8nhBL9JA z>ZaT<9&oBIzt&h%N0p$41Q3j`FA4v#h9uTXl^zFzNW7YXx2R#3k{mC$S-f!$8|)qe z?oA&mGBtR&#fLYsiu!=7Q{z<{S&SV>LBQK^Nx*AgM5H0$B;pu`X<0Uss1*7(}~vL=;xQ6ND~8fKtg_<;P5n|UZD)m83Tx9QN%FphxGtC6-XjyLPV_URCB;ejQ&bI%CKAz_Ghxs-=e%le{U&@EJ z#?0O?nXDm9esFi^GUR2TJs)?={P<%@?McIeRGnTVL)MR&9Lex+}VYF;Y}#;8rvl zE|~o{B*=_^EBfujnDfB8*iJRg2QHQ%{8tUzE@+oQ0B8L2;>(~Vk=yUvYeF)0C_l3A zeQ{coPg>cw7MI%g=|Yl1lVb0|tPdN8VeYv~;zg+3JZ#<>=bsrDFmXni^6%LvO`1F5 z-mT{Ywi{O}Cobg~q(5DgTj%VqYax8g*)tW{rYs-3RV7dc>rVV(kZ(2xF1Gm)^EuY7 zjzcX!|K~Rs-O5X}EIL584|J4uXnEe8nYbt0pG~TZC7+jsDY@hpb27bV3{&?(gA<_{ z&FBlAGYRCnM}Y5sb=L)#2C3@~3j8qPZ{Tgve47{Q1Una)JPTNu@nx&@@aG?%Jt~V8 z3a!tPwTuM8<0uFVmeSfqBp|o&Kx|*EE)we^UM~;tW=i!6QI6a?pl&ri3WS#k*rmPhN9(Jk0d^hPoM|WORW&h}8t(^E(lM5glEtn15+NXa3 z@-pMPmvNnNz=r=DYu-7NRnlqn;EVyLB`Z@=;BSh=eTH0=A;+;gkez>WH-w`c)x7|T ze0LfSZ;8k;IF2U~pCh_||Nh-KFtFZExsQcr0-6iO!HF132u1t&pp>%32})Dd;=MkUhXJD1 z|NU>cSfKUER?9+N7@5+B0XVtCpr#L9_^C%qcx}mTw#;Y`sbT4`L}K~U3%-#`=G`Ck zY8lcn&>@DwA~oLxS6h1QBn2BX1S(J=DI~kz4UN3s#j-2MF^T?ZC)9@NFmHw<3yB(8 znem|@Od9YMqIp*;^-iZh53GY)1j30f`0VVC*p<-JXBLr@Ah16Ayy5EBPgbmRrIR#%ICPCjYCY1wcW+z7TXiW_!C# zYpS5}=+DSA#h>FyLr2;cN<$~gi^q0vP82-Gzg4%JKPt*$Lmdlw_UqGFQ- z;Ucm=367Q4nm66JsD7Dvv#2ZgYUj$%k2;1HbZIzOr8mWp=*}rl| zwWPm+s)z3+`X|QlfUDCaeu-CB?5{ivY{+R)Yf-?iL4UlhBqDpAbFAq^axviF&~LLF z!G|lA&%%0mYXGGOMiq87Id#|jCZG}mb312UpNJ$v zP%Xu3cs@bTTFta{xMUztE)2SbVDFXMk+x{6>-8e-F&02ACr)tR->lrVZ915g-K(lr zNVA-W&vZJwbOF`PTS>~&h(m#pi3#Gh*uNLdVmDEA`Gk;JAyVP~Cwvs<;1Yb5#R2hy z`Sd&_XlHVHBA%g+bi5t((uC^-Vboyt^?^EGo;eJnIsZR{CU1+3Sd#jP#7 zE!yezVogkU&ZEoW46d1%v5OQWa6A2+euW=yU*2`-ngl);+yY(w2o=Y*Rb!phh@l`iYhwgOfO;gu#GZ!R}W|E2g+>h4n8v}*OI%N ziesagD#je{6JjOZUI$ehP86~xaN+eU^O0Bmjn`CG8rLiXzc6&JU6R|i68DZv5ZhMb z-KmZds@c6Vc)fAwZhi(pAy12PDQ6B_~rfRS>9qgneoBRpoWw~@c zqnn^R5N*%M8F*gMB|H0?BA`;kE*y7k9!|xOa+$a{Kj*c$MYX_5_(6JTK<3zS-j0Jo z+o*oWySc|_fwhAO^3U3`V^j}f*SF=@S%%1%Pisvt^!u>zp~{!Y`m_P{{W&|_fmBh{ zpzSryk!{VC*4qKDp=plzA~e$Wr5;f6Or%)yshg5}O{8;(=V%DJULXT-FS)cVa9dz5 zAfR{odjDCG2^Y^%{WL0F_jT_YG`LBpraqGW`nWcvy=lqEp^4;dBhB8 zQ+@_5C7?hq1q#C3h6ROJq6yX@N|9{_2L=YBJcv+TvaH`eGNHPu2BP4~Jn0s_=U)pX zvz^ny`3~tKymbv(EvkIn-sHW`#{+&(4S@5DvWMSjnt<4gcUxZRl5}$3GdI_80G|&6n|~}l*3PB3-iqIsIa7QxT6N+26U7m-gXN41g4GF}kj=w$`amo-m9m^aQls2a&VWmcVsrtw9Pj)N> z34dEuN2{oHfxZd1WQ>XSL1B+m+s5GV@Z+b>pA#QG%OXHWOJJjJBNsuV&BD3Eq@9&wB^#wDLkF}4n`;eYqo`9}dlL-v3wzAlKI*uwYFW1Y=t~ki^nPD^Q{S3&X z!J7wmhsik(e}26V)>tv){#ec!ABuW1KtOJ7V`i55-WByWsOd=})M-V+=r|_%C(g z{xMFiEiKtk&3_ly#sG+Q-84i_V-UrNQc0?yt*ET@1DO;&eH04D3(Z8`;dt+^ODwxw z1r%=O!z!Q8Z(c>?;=g0TgK`$oZYI<3aEOqqs%9mw^fW2-w5;2&VQ3WM8jq#WdN*H< z=Eb$1U8?PI=6|#E-+x}?#XCC-Y0qxxmRl9m04%10fX)P^W7hZ3&yiQblMMrYYK>x% zUhgr_+`fMNfPJbi26!@B?Uz5gS9Y&gF@{#bvqD}@V~th@SYFrjf9@J0#btAMNvcw6 ziIP?EymwFgIz>;Vq$^id!Sh+=z`#u_+>y(tF@<`<9zSq>{l+IFr0T$Go!*3wiE*t- zD-`5^bOcuWT-9iDVYJgSZd4900}avcdz-d?&E{7FCSG~G>DPqM_Z%L&-N>fS=7Q)Ee&4e8@$V=VHO2}RJ8{&3 zK1-khyS47VnovbqfEYu^(!nf9|eTuIq1L=W=U&rI7 k9cZB`8HJ+Q+~;E=@0pV7rY#*==x$j^>x?u%XxN7TKY#&d3jhEB literal 4286 zcmeHL>rYc>81ELdEe;}zmYd}cUgmJRfwjUwD1`#s5KZP>mMqza#Viv|_7|8f+0+bX zHuqusuw-7Ca`DTu#4U4^o2bjO#K>4%N?Wdi*wZ3Vx%~Ef4}D1`U_EMRg3u z#2#M|V>}}q-@IaO@{9R}d*u7f&~5HfxSkmHVcazU#i30H zAGxQ5Spe!j9`KuGqR@aExK`-}sH1jvqoIp3C7Vm)9Tu=UPE;j^esN~a6^a$ZILngo;^ zGLXl(ZFyY&U!li`6}y-hUQ99v?s`U4O!kgog74FPw-9g+V)qs!jFGEQyvBf><U|E2vRmx|+(VI~S=lT?@~C5pvZOd`x{Q_+3tG6H=gtdWcf z)+7-Zp=UqH^J4sk^>_G-Ufn-2Hz z2mN12|C{5}U`^eCQuFz=F%wp@}SzA1MHEaM^CtJs<{}Tzu$bx2orTKiedgmtVGM{ zdd#vX`&cuiec|My_KW;y{Ryz2kFu9}=~us6hvx1ZqQCk(d+>HP>ks>mmHCjjDh{pe zKQkKpk0SeDX#XMqf$}QV{z=xrN!mQczJAvud@;zFqaU1ocq==Py)qsa=8UKrt!J7r z{RsTo^rgtZo%$rak)DN*D)!(Y^$@yL6Nd=#eu&?unzhH8yq>v{gkt8xcG3S%H)-y_ zqQ1|v|JT$0R~Y}omg2Y+nDvR+K|kzR5i^fmKF>j~N;A35Vr`JWh4yRqKl#P|qlx?` z@|CmBiP}ysYO%m2{eBG6&ix5 zr#u((F2{vb=W4jNmTQh3M^F2o80T49?w>*rv0mt)-o1y!{hRk`E#UVPdna6jnz`rw dKpn)r^--YJZpr;bYU`N~>#v3X5BRU&{{=gv-{1fM diff --git a/src/witty_wisterias/backend/database.py b/src/witty_wisterias/backend/database.py index a72d3869..2e4fc9e0 100644 --- a/src/witty_wisterias/backend/database.py +++ b/src/witty_wisterias/backend/database.py @@ -20,7 +20,7 @@ UPLOAD_URL = HOSTER_URL + "/upload" JSON_URL = HOSTER_URL + "/json" # Search Term used to query for our images (and name our files) -FILE_SEARCH_TERM = "WittyWisteriasV8" +FILE_SEARCH_TERM = "ShitChatV1" class Database: diff --git a/src/witty_wisterias/frontend/components/sidebar.py b/src/witty_wisterias/frontend/components/sidebar.py index e02a29f6..c1e66e85 100644 --- a/src/witty_wisterias/frontend/components/sidebar.py +++ b/src/witty_wisterias/frontend/components/sidebar.py @@ -17,13 +17,14 @@ def chat_sidebar() -> rx.Component: return rx.el.div( rx.vstack( rx.hstack( - rx.heading("Witty Wisterias", size="6"), + rx.heading("ShitChat", size="6"), rx.heading("v1.0.0", size="3", class_name="text-gray-500"), - spacing="2", + spacing="0", align="baseline", justify="between", class_name="w-full mb-0", ), + rx.heading("by Witty Wisterias", size="2", class_name="text-gray-400 -mt-4", spacing="0"), rx.divider(), rx.heading("Public Chat", size="2", class_name="text-gray-500"), rx.button( diff --git a/src/witty_wisterias/frontend/frontend.py b/src/witty_wisterias/frontend/frontend.py index 61e9ef78..703711b4 100644 --- a/src/witty_wisterias/frontend/frontend.py +++ b/src/witty_wisterias/frontend/frontend.py @@ -7,7 +7,7 @@ from frontend.states.chat_state import ChatState -@rx.page(on_load=ChatState.startup_event) +@rx.page(title="ShitChat by Witty Wisterias", on_load=ChatState.startup_event) def index() -> rx.Component: """The main page of the chat application, which includes the sidebar and chat app components.""" return rx.cond( From 62ecf502bbc73ac36cb0ed998dfcd880eeb2543b Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:52:41 +0200 Subject: [PATCH 49/56] Move from /src/witty_wisterias/ to /witty_wisterias/ --- src/witty_wisterias/frontend/states/__init__.py | 0 {src/witty_wisterias => witty_wisterias}/.gitignore | 0 .../witty_wisterias => witty_wisterias}/__main__.py | 0 .../assets/favicon.ico | Bin {src => witty_wisterias/backend}/__init__.py | 0 .../backend/backend.py | 0 .../backend/cryptographer.py | 0 .../backend/database.py | 0 .../backend/exceptions.py | 0 .../backend/message_format.py | 0 .../backend/user_input_handler.py | 0 .../frontend}/__init__.py | 0 .../frontend/app_config.py | 0 .../frontend/components}/__init__.py | 0 .../frontend/components/chat_bubble.py | 0 .../frontend/components/chatapp.py | 0 .../frontend/components/create_chat.py | 0 .../frontend/components/image_button.py | 0 .../frontend/components/sidebar.py | 0 .../frontend/components/text_button.py | 2 +- .../frontend/components/tos_accept_form.py | 0 .../frontend/components/user_info.py | 0 .../frontend/frontend.py | 0 .../frontend/states}/__init__.py | 0 .../frontend/states/chat_state.py | 0 .../frontend/states/progress_state.py | 0 .../frontend/states/webcam_state.py | 0 .../requirements.txt | 0 .../witty_wisterias => witty_wisterias}/rxconfig.py | 0 29 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 src/witty_wisterias/frontend/states/__init__.py rename {src/witty_wisterias => witty_wisterias}/.gitignore (100%) rename {src/witty_wisterias => witty_wisterias}/__main__.py (100%) rename {src/witty_wisterias => witty_wisterias}/assets/favicon.ico (100%) rename {src => witty_wisterias/backend}/__init__.py (100%) rename {src/witty_wisterias => witty_wisterias}/backend/backend.py (100%) rename {src/witty_wisterias => witty_wisterias}/backend/cryptographer.py (100%) rename {src/witty_wisterias => witty_wisterias}/backend/database.py (100%) rename {src/witty_wisterias => witty_wisterias}/backend/exceptions.py (100%) rename {src/witty_wisterias => witty_wisterias}/backend/message_format.py (100%) rename {src/witty_wisterias => witty_wisterias}/backend/user_input_handler.py (100%) rename {src/witty_wisterias/backend => witty_wisterias/frontend}/__init__.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/app_config.py (100%) rename {src/witty_wisterias/frontend => witty_wisterias/frontend/components}/__init__.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/components/chat_bubble.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/components/chatapp.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/components/create_chat.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/components/image_button.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/components/sidebar.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/components/text_button.py (97%) rename {src/witty_wisterias => witty_wisterias}/frontend/components/tos_accept_form.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/components/user_info.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/frontend.py (100%) rename {src/witty_wisterias/frontend/components => witty_wisterias/frontend/states}/__init__.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/states/chat_state.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/states/progress_state.py (100%) rename {src/witty_wisterias => witty_wisterias}/frontend/states/webcam_state.py (100%) rename {src/witty_wisterias => witty_wisterias}/requirements.txt (100%) rename {src/witty_wisterias => witty_wisterias}/rxconfig.py (100%) diff --git a/src/witty_wisterias/frontend/states/__init__.py b/src/witty_wisterias/frontend/states/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/witty_wisterias/.gitignore b/witty_wisterias/.gitignore similarity index 100% rename from src/witty_wisterias/.gitignore rename to witty_wisterias/.gitignore diff --git a/src/witty_wisterias/__main__.py b/witty_wisterias/__main__.py similarity index 100% rename from src/witty_wisterias/__main__.py rename to witty_wisterias/__main__.py diff --git a/src/witty_wisterias/assets/favicon.ico b/witty_wisterias/assets/favicon.ico similarity index 100% rename from src/witty_wisterias/assets/favicon.ico rename to witty_wisterias/assets/favicon.ico diff --git a/src/__init__.py b/witty_wisterias/backend/__init__.py similarity index 100% rename from src/__init__.py rename to witty_wisterias/backend/__init__.py diff --git a/src/witty_wisterias/backend/backend.py b/witty_wisterias/backend/backend.py similarity index 100% rename from src/witty_wisterias/backend/backend.py rename to witty_wisterias/backend/backend.py diff --git a/src/witty_wisterias/backend/cryptographer.py b/witty_wisterias/backend/cryptographer.py similarity index 100% rename from src/witty_wisterias/backend/cryptographer.py rename to witty_wisterias/backend/cryptographer.py diff --git a/src/witty_wisterias/backend/database.py b/witty_wisterias/backend/database.py similarity index 100% rename from src/witty_wisterias/backend/database.py rename to witty_wisterias/backend/database.py diff --git a/src/witty_wisterias/backend/exceptions.py b/witty_wisterias/backend/exceptions.py similarity index 100% rename from src/witty_wisterias/backend/exceptions.py rename to witty_wisterias/backend/exceptions.py diff --git a/src/witty_wisterias/backend/message_format.py b/witty_wisterias/backend/message_format.py similarity index 100% rename from src/witty_wisterias/backend/message_format.py rename to witty_wisterias/backend/message_format.py diff --git a/src/witty_wisterias/backend/user_input_handler.py b/witty_wisterias/backend/user_input_handler.py similarity index 100% rename from src/witty_wisterias/backend/user_input_handler.py rename to witty_wisterias/backend/user_input_handler.py diff --git a/src/witty_wisterias/backend/__init__.py b/witty_wisterias/frontend/__init__.py similarity index 100% rename from src/witty_wisterias/backend/__init__.py rename to witty_wisterias/frontend/__init__.py diff --git a/src/witty_wisterias/frontend/app_config.py b/witty_wisterias/frontend/app_config.py similarity index 100% rename from src/witty_wisterias/frontend/app_config.py rename to witty_wisterias/frontend/app_config.py diff --git a/src/witty_wisterias/frontend/__init__.py b/witty_wisterias/frontend/components/__init__.py similarity index 100% rename from src/witty_wisterias/frontend/__init__.py rename to witty_wisterias/frontend/components/__init__.py diff --git a/src/witty_wisterias/frontend/components/chat_bubble.py b/witty_wisterias/frontend/components/chat_bubble.py similarity index 100% rename from src/witty_wisterias/frontend/components/chat_bubble.py rename to witty_wisterias/frontend/components/chat_bubble.py diff --git a/src/witty_wisterias/frontend/components/chatapp.py b/witty_wisterias/frontend/components/chatapp.py similarity index 100% rename from src/witty_wisterias/frontend/components/chatapp.py rename to witty_wisterias/frontend/components/chatapp.py diff --git a/src/witty_wisterias/frontend/components/create_chat.py b/witty_wisterias/frontend/components/create_chat.py similarity index 100% rename from src/witty_wisterias/frontend/components/create_chat.py rename to witty_wisterias/frontend/components/create_chat.py diff --git a/src/witty_wisterias/frontend/components/image_button.py b/witty_wisterias/frontend/components/image_button.py similarity index 100% rename from src/witty_wisterias/frontend/components/image_button.py rename to witty_wisterias/frontend/components/image_button.py diff --git a/src/witty_wisterias/frontend/components/sidebar.py b/witty_wisterias/frontend/components/sidebar.py similarity index 100% rename from src/witty_wisterias/frontend/components/sidebar.py rename to witty_wisterias/frontend/components/sidebar.py diff --git a/src/witty_wisterias/frontend/components/text_button.py b/witty_wisterias/frontend/components/text_button.py similarity index 97% rename from src/witty_wisterias/frontend/components/text_button.py rename to witty_wisterias/frontend/components/text_button.py index c71d7014..edea0ab5 100644 --- a/src/witty_wisterias/frontend/components/text_button.py +++ b/witty_wisterias/frontend/components/text_button.py @@ -64,7 +64,7 @@ def send_text_component() -> rx.Component: rx.dialog.title("Send Text"), rx.dialog.description( "Send a text message to the chat by writing your message on a physical peace of paper and taking a" - "picture of it with your webcam.", + " picture of it with your webcam.", size="2", margin_bottom="16px", ), diff --git a/src/witty_wisterias/frontend/components/tos_accept_form.py b/witty_wisterias/frontend/components/tos_accept_form.py similarity index 100% rename from src/witty_wisterias/frontend/components/tos_accept_form.py rename to witty_wisterias/frontend/components/tos_accept_form.py diff --git a/src/witty_wisterias/frontend/components/user_info.py b/witty_wisterias/frontend/components/user_info.py similarity index 100% rename from src/witty_wisterias/frontend/components/user_info.py rename to witty_wisterias/frontend/components/user_info.py diff --git a/src/witty_wisterias/frontend/frontend.py b/witty_wisterias/frontend/frontend.py similarity index 100% rename from src/witty_wisterias/frontend/frontend.py rename to witty_wisterias/frontend/frontend.py diff --git a/src/witty_wisterias/frontend/components/__init__.py b/witty_wisterias/frontend/states/__init__.py similarity index 100% rename from src/witty_wisterias/frontend/components/__init__.py rename to witty_wisterias/frontend/states/__init__.py diff --git a/src/witty_wisterias/frontend/states/chat_state.py b/witty_wisterias/frontend/states/chat_state.py similarity index 100% rename from src/witty_wisterias/frontend/states/chat_state.py rename to witty_wisterias/frontend/states/chat_state.py diff --git a/src/witty_wisterias/frontend/states/progress_state.py b/witty_wisterias/frontend/states/progress_state.py similarity index 100% rename from src/witty_wisterias/frontend/states/progress_state.py rename to witty_wisterias/frontend/states/progress_state.py diff --git a/src/witty_wisterias/frontend/states/webcam_state.py b/witty_wisterias/frontend/states/webcam_state.py similarity index 100% rename from src/witty_wisterias/frontend/states/webcam_state.py rename to witty_wisterias/frontend/states/webcam_state.py diff --git a/src/witty_wisterias/requirements.txt b/witty_wisterias/requirements.txt similarity index 100% rename from src/witty_wisterias/requirements.txt rename to witty_wisterias/requirements.txt diff --git a/src/witty_wisterias/rxconfig.py b/witty_wisterias/rxconfig.py similarity index 100% rename from src/witty_wisterias/rxconfig.py rename to witty_wisterias/rxconfig.py From 41920966004f328e62ad48216ceb45c058cb6dd4 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:43:33 +0200 Subject: [PATCH 50/56] Fix Linting --- witty_wisterias/backend/user_input_handler.py | 3 +- .../frontend/components/chatapp.py | 28 +++++++-------- .../frontend/components/sidebar.py | 2 +- witty_wisterias/frontend/states/chat_state.py | 34 ++++++++++--------- .../frontend/states/webcam_state.py | 2 +- 5 files changed, 36 insertions(+), 33 deletions(-) diff --git a/witty_wisterias/backend/user_input_handler.py b/witty_wisterias/backend/user_input_handler.py index c9d13655..44e47394 100644 --- a/witty_wisterias/backend/user_input_handler.py +++ b/witty_wisterias/backend/user_input_handler.py @@ -43,7 +43,8 @@ def image_to_text(image_base64: str) -> str: # Getting the Response data page_data = response_json.get("data", {}).get("response", {}) # Returning the extracted Text - return page_data.get("natural_text", "No text found.") + extracted_text: str = page_data.get("natural_text", "No text found.") + return extracted_text @staticmethod def text_to_image(text: str) -> str: diff --git a/witty_wisterias/frontend/components/chatapp.py b/witty_wisterias/frontend/components/chatapp.py index d686cd5f..c4334db7 100644 --- a/witty_wisterias/frontend/components/chatapp.py +++ b/witty_wisterias/frontend/components/chatapp.py @@ -18,31 +18,31 @@ def chat_specific_messages(message: MessageState) -> rx.Component: rx.Component: A component representing the chat bubble for the message, if it fits. """ # Storing Message/User Attributes for easier access - user_id = message.get("user_id") - receiver_id = message.get("receiver_id") + user_id = message.user_id + receiver_id = message.receiver_id selected_chat = ChatState.selected_chat return rx.cond( # Public Chat Messages - (selected_chat == "Public") & (~receiver_id), + (selected_chat == "Public") & (~receiver_id), # type: ignore[operator] chat_bubble_component( - message["message"], - rx.cond(message["user_name"], message["user_name"], user_id), + message.message, + rx.cond(message.user_name, message.user_name, user_id), user_id, - message["user_profile_image"], - message["own_message"], - message["is_image_message"], + message.user_profile_image, + message.own_message, + message.is_image_message, ), rx.cond( # Private Chat Messages - (selected_chat != "Public") & receiver_id & ((selected_chat == receiver_id) | (selected_chat == user_id)), + (selected_chat != "Public") & receiver_id & ((selected_chat == receiver_id) | (selected_chat == user_id)), # type: ignore[operator] chat_bubble_component( - message["message"], - rx.cond(message["user_name"], message["user_name"], user_id), + message.message, + rx.cond(message.user_name, message.user_name, user_id), user_id, - message["user_profile_image"], - message["own_message"], - message["is_image_message"], + message.user_profile_image, + message.own_message, + message.is_image_message, ), # Fallback rx.fragment(), diff --git a/witty_wisterias/frontend/components/sidebar.py b/witty_wisterias/frontend/components/sidebar.py index c1e66e85..4fbe1968 100644 --- a/witty_wisterias/frontend/components/sidebar.py +++ b/witty_wisterias/frontend/components/sidebar.py @@ -70,7 +70,7 @@ def chat_sidebar() -> rx.Component: src=ChatState.user_profile_image, fallback=ChatState.user_id[:2], radius="large", size="3" ), rx.vstack( - rx.text(ChatState.user_name | ChatState.user_id, size="3"), + rx.text(ChatState.user_name | ChatState.user_id, size="3"), # type: ignore[operator] rx.text(ChatState.user_id, size="1", class_name="text-gray-500"), spacing="0", ), diff --git a/witty_wisterias/frontend/states/chat_state.py b/witty_wisterias/frontend/states/chat_state.py index c5be640a..83ac0ce5 100644 --- a/witty_wisterias/frontend/states/chat_state.py +++ b/witty_wisterias/frontend/states/chat_state.py @@ -131,7 +131,7 @@ def select_chat(self, chat_name: str) -> Generator[None, None]: yield @rx.event - def start_webcam(self, _: dict[str, str]) -> None: + def start_webcam(self, _: dict[str, str]) -> Generator[None, None]: """ Start the webcam capture loop. @@ -142,7 +142,7 @@ def start_webcam(self, _: dict[str, str]) -> None: yield ChatState.capture_loop @rx.event - async def send_public_text(self, _: dict[str, Any]) -> Generator[None, None]: + def send_public_text(self, _: dict[str, Any]) -> Generator[None, None]: """ Reflex Event when a text message is sent. @@ -403,10 +403,11 @@ async def check_messages(self) -> None: self.add_key_storage("public_keys", user_id, public_key) # Public Chat Messages - for message in public_messages: + for public_message in public_messages: # Check if the message is already in the chat using timestamp message_exists = any( - all_messages.timestamp == message.timestamp and all_messages.user_id == message.sender_id + all_messages.timestamp == public_message.timestamp + and all_messages.user_id == public_message.sender_id for all_messages in self.messages ) @@ -415,14 +416,14 @@ async def check_messages(self) -> None: # Convert the Backend Format to the Frontend Format (MessageState) self.messages.append( MessageState( - message=message.content, - user_id=message.sender_id, - user_name=message.extra_event_info.user_name, + message=public_message.content, + user_id=public_message.sender_id, + user_name=str(public_message.extra_event_info.user_name), receiver_id=None, - user_profile_image=message.extra_event_info.user_image, - own_message=self.user_id == message.sender_id, - is_image_message=message.event_type == EventType.PUBLIC_IMAGE, - timestamp=message.timestamp, + user_profile_image=public_message.extra_event_info.user_image, + own_message=self.user_id == public_message.sender_id, + is_image_message=public_message.event_type == EventType.PUBLIC_IMAGE, + timestamp=public_message.timestamp, ) ) @@ -441,18 +442,19 @@ async def check_messages(self) -> None: backend_private_messages + own_private_messages, key=lambda msg: msg.timestamp, ) - for message in sorted_private_messages: + for private_message in sorted_private_messages: # Add received chat partner to chat partners list - if message.user_id != self.user_id: - self.register_chat_partner(message.user_id) + if private_message.user_id != self.user_id: + self.register_chat_partner(private_message.user_id) # Check if the message is already in the chat using timestamp message_exists = any( - msg.timestamp == message.timestamp and msg.user_id == message.user_id for msg in self.messages + msg.timestamp == private_message.timestamp and msg.user_id == private_message.user_id + for msg in self.messages ) # Check if message is not already in the chat if not message_exists: - self.messages.append(message) + self.messages.append(private_message) # Wait for 5 seconds before checking for new messages again to avoid excessive load await asyncio.sleep(5) diff --git a/witty_wisterias/frontend/states/webcam_state.py b/witty_wisterias/frontend/states/webcam_state.py index adb78f82..8cded7df 100644 --- a/witty_wisterias/frontend/states/webcam_state.py +++ b/witty_wisterias/frontend/states/webcam_state.py @@ -20,7 +20,7 @@ webcam_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) -class WebcamStateMixin(rx.State, mixin=True): +class WebcamStateMixin(rx.State, mixin=True): # type: ignore[call-arg] """Mixin for managing webcam state in the application.""" frame_data: str | None = None From 4606ce2ec3b55e789fe8c1490b01b85c78d5abe2 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 18 Aug 2025 18:10:24 +0200 Subject: [PATCH 51/56] Move from Threading to run_in_executor --- .../frontend/components/chat_bubble.py | 2 +- witty_wisterias/frontend/states/chat_state.py | 52 ++++++++----------- 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/witty_wisterias/frontend/components/chat_bubble.py b/witty_wisterias/frontend/components/chat_bubble.py index 2109b771..153d37d4 100644 --- a/witty_wisterias/frontend/components/chat_bubble.py +++ b/witty_wisterias/frontend/components/chat_bubble.py @@ -16,7 +16,7 @@ def chat_bubble_component( Creates a chat bubble component for displaying messages in the chat application. Args: - message (str): The content of the message, either text or base64-encoded image. + message (str | Image.Image): The content of the message, either text or base64-encoded image. user_name (str): The name of the user who sent the message. user_id (str): The UserID of the user who sent the message. user_profile_image (str): The URL of the user's profile image. diff --git a/witty_wisterias/frontend/states/chat_state.py b/witty_wisterias/frontend/states/chat_state.py index 83ac0ce5..010678e5 100644 --- a/witty_wisterias/frontend/states/chat_state.py +++ b/witty_wisterias/frontend/states/chat_state.py @@ -2,7 +2,6 @@ import base64 import io import json -import threading from collections.abc import AsyncGenerator, Generator from datetime import UTC, datetime from typing import Any, Literal, cast @@ -142,7 +141,7 @@ def start_webcam(self, _: dict[str, str]) -> Generator[None, None]: yield ChatState.capture_loop @rx.event - def send_public_text(self, _: dict[str, Any]) -> Generator[None, None]: + async def send_public_text(self, _: dict[str, Any]) -> AsyncGenerator[None, None]: """ Reflex Event when a text message is sent. @@ -185,15 +184,13 @@ def send_public_text(self, _: dict[str, Any]) -> Generator[None, None]: sender_username=self.user_name, sender_profile_image=self.user_profile_image, ) - # Note: We need to use threading here, even if it looks odd. This is because the - # Backend.send_public_message method blocks the UI thread. So we need to run it in a separate thread. - # Something like asyncio.to_thread or similar doesn't work here, not 100% sure why, my best guess is - # that it does not separate from the UI thread properly. So threading is the next best option. - # If you find something better, please update this. - threading.Thread(target=Backend.send_public_message, args=(message_format,), daemon=True).start() + # To not block the UI thread, we run this in an executor before the async with self. + loop = asyncio.get_running_loop() + # Send the Message without blocking the UI thread. + await loop.run_in_executor(None, Backend.send_public_message, message_format) @rx.event - def send_public_image(self, form_data: dict[str, Any]) -> Generator[None, None]: + async def send_public_image(self, form_data: dict[str, Any]) -> AsyncGenerator[None, None]: """ Reflex Event when an image message is sent. @@ -239,15 +236,13 @@ def send_public_image(self, form_data: dict[str, Any]) -> Generator[None, None]: sender_username=self.user_name, sender_profile_image=self.user_profile_image, ) - # Note: We need to use threading here, even if it looks odd. This is because the - # Backend.send_public_message method blocks the UI thread. So we need to run it in a separate thread. - # Something like asyncio.to_thread or similar doesn't work here, not 100% sure why, my best guess is - # that it does not separate from the UI thread properly. So threading is the next best option. - # If you find something better, please update this. - threading.Thread(target=Backend.send_public_message, args=(message_format,), daemon=True).start() + # To not block the UI thread, we run this in an executor before the async with self. + loop = asyncio.get_running_loop() + # Send the Message without blocking the UI thread. + await loop.run_in_executor(None, Backend.send_public_message, message_format) @rx.event - def send_private_text(self, form_data: dict[str, Any]) -> Generator[None, None]: + async def send_private_text(self, form_data: dict[str, Any]) -> AsyncGenerator[None, None]: """ Reflex Event when a private text message is sent. @@ -307,15 +302,13 @@ def send_private_text(self, form_data: dict[str, Any]) -> Generator[None, None]: sender_username=self.user_name, sender_profile_image=self.user_profile_image, ) - # Note: We need to use threading here, even if it looks odd. This is because the - # Backend.send_private_message method blocks the UI thread. So we need to run it in a separate thread. - # Something like asyncio.to_thread or similar doesn't work here, not 100% sure why, my best guess is - # that it does not separate from the UI thread properly. So threading is the next best option. - # If you find something better, please update this. - threading.Thread(target=Backend.send_private_message, args=(message_format,), daemon=True).start() + # To not block the UI thread, we run this in an executor before the async with self. + loop = asyncio.get_running_loop() + # Send the Message without blocking the UI thread. + await loop.run_in_executor(None, Backend.send_private_message, message_format) @rx.event - def send_private_image(self, form_data: dict[str, Any]) -> Generator[None, None]: + async def send_private_image(self, form_data: dict[str, Any]) -> AsyncGenerator[None, None]: """ Reflex Event when a private image message is sent. @@ -377,12 +370,10 @@ def send_private_image(self, form_data: dict[str, Any]) -> Generator[None, None] sender_username=self.user_name, sender_profile_image=self.user_profile_image, ) - # Note: We need to use threading here, even if it looks odd. This is because the - # Backend.send_private_message method blocks the UI thread. So we need to run it in a separate thread. - # Something like asyncio.to_thread or similar doesn't work here, not 100% sure why, my best guess is - # that it does not separate from the UI thread properly. So threading is the next best option. - # If you find something better, please update this. - threading.Thread(target=Backend.send_private_message, args=(message_format,), daemon=True).start() + # To not block the UI thread, we run this in an executor before the async with self. + loop = asyncio.get_running_loop() + # Send the Message without blocking the UI thread. + await loop.run_in_executor(None, Backend.send_private_message, message_format) @rx.event(background=True) async def check_messages(self) -> None: @@ -390,8 +381,11 @@ async def check_messages(self) -> None: while True: # To not block the UI thread, we run this in an executor before the async with self. loop = asyncio.get_running_loop() + # Reading Verify and Public Keys from Database verify_keys, public_keys = await loop.run_in_executor(None, Backend.read_public_keys) + # Reading Public Messages from Database public_messages = await loop.run_in_executor(None, Backend.read_public_messages) + # Reading Private Messages from Database backend_private_message_formats = await loop.run_in_executor( None, Backend.read_private_messages, self.user_id, self.private_key ) From e319b895d645004c5bae25c824fbc7f2ad46f0f0 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 18 Aug 2025 18:14:47 +0200 Subject: [PATCH 52/56] Update Requirements --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9d335075..1155469f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,12 @@ pre-commit~=4.2.0 ruff~=0.12.7 setuptools~=80.9.0 -types-requests # Project dependencies beautifulsoup4~=4.13.4 httpx~=0.28.1 +opencv-python~=4.12.0.88 pillow~=11.3.0 PyNaCl~=1.5.0 reflex~=0.8.5 +websockets~=15.0.1 xxhash~=3.5.0 From 7d8ac51b345e9817ee25bf79c73b9fb6e051b7f2 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 18 Aug 2025 18:17:06 +0200 Subject: [PATCH 53/56] Ensure User is Logged In to Check Messages --- witty_wisterias/frontend/states/chat_state.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/witty_wisterias/frontend/states/chat_state.py b/witty_wisterias/frontend/states/chat_state.py index 010678e5..4935c579 100644 --- a/witty_wisterias/frontend/states/chat_state.py +++ b/witty_wisterias/frontend/states/chat_state.py @@ -13,6 +13,7 @@ from backend.user_input_handler import UserInputHandler from PIL import Image +from frontend.app_config import app from frontend.states.progress_state import ProgressState from frontend.states.webcam_state import WebcamStateMixin @@ -378,7 +379,8 @@ async def send_private_image(self, form_data: dict[str, Any]) -> AsyncGenerator[ @rx.event(background=True) async def check_messages(self) -> None: """Reflex Background Check for new messages.""" - while True: + # Run while tab is open + while self.router.session.client_token in app.event_namespace.token_to_sid: # To not block the UI thread, we run this in an executor before the async with self. loop = asyncio.get_running_loop() # Reading Verify and Public Keys from Database From d43532d5c7c1df659fca357bce46625e431e1728 Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Mon, 18 Aug 2025 18:24:54 +0200 Subject: [PATCH 54/56] Some more formatting --- witty_wisterias/backend/backend.py | 5 +++++ witty_wisterias/backend/database.py | 3 +++ witty_wisterias/backend/user_input_handler.py | 1 + witty_wisterias/frontend/states/chat_state.py | 8 ++++++++ 4 files changed, 17 insertions(+) diff --git a/witty_wisterias/backend/backend.py b/witty_wisterias/backend/backend.py index e4295408..56171bc8 100644 --- a/witty_wisterias/backend/backend.py +++ b/witty_wisterias/backend/backend.py @@ -62,6 +62,7 @@ def decode(encoded_stack: str) -> UploadStack: # Check if the Message Stack is completely empty if not encoded_stack: return UploadStack() + compressed_stack = base64.b64decode(encoded_stack.encode("utf-8")) # Decompress string_stack = zlib.decompress(compressed_stack).decode("utf-8") @@ -231,9 +232,11 @@ def read_public_messages() -> list[MessageFormat]: for message in queried_data.message_stack: if isinstance(message, str): continue + # Checking if the message is a public message if message.event_type not in (EventType.PUBLIC_TEXT, EventType.PUBLIC_IMAGE): continue + # Signature Verification if message.sender_id in queried_data.verify_keys_stack: verify_key = queried_data.verify_keys_stack[message.sender_id] @@ -274,9 +277,11 @@ def read_private_messages(user_id: str, private_key: str) -> list[MessageFormat] for message in queried_data.message_stack: if isinstance(message, str): continue + # Checking if the message is a private message if message.event_type not in (EventType.PRIVATE_TEXT, EventType.PRIVATE_IMAGE): continue + # Message Decryption check if message.receiver_id == user_id and message.sender_id in queried_data.public_keys_stack: try: diff --git a/witty_wisterias/backend/database.py b/witty_wisterias/backend/database.py index 2e4fc9e0..8d2cb31a 100644 --- a/witty_wisterias/backend/database.py +++ b/witty_wisterias/backend/database.py @@ -46,6 +46,7 @@ def extract_timestamp(url: str) -> float: match = re.search(r"(\d+\.\d+)", url) if match: return float(match.group(1)) + # If no match is found, return 0.0 as a default value return 0.0 @staticmethod @@ -80,6 +81,7 @@ def base64_to_image(data: bytes) -> bytes: # Save as PNG (lossless) in memory buffer = BytesIO() pil_image.save(buffer, format="PNG") + # Get the byte content of the image file image_bytes = buffer.getvalue() # Check File Size (Image Hosting Service Limit) @@ -193,6 +195,7 @@ def query_data() -> str: no_header_data = pixel_byte_data[len(FILE_SEARCH_TERM.encode()) + 8 :] # Remove any padding bytes (if any) to get the original data no_padding_data = no_header_data.rstrip(b"\x00") + # Decode bytes into string and return it decoded_data: str = no_padding_data.decode("utf-8", errors="ignore") return decoded_data diff --git a/witty_wisterias/backend/user_input_handler.py b/witty_wisterias/backend/user_input_handler.py index 44e47394..37ec7c70 100644 --- a/witty_wisterias/backend/user_input_handler.py +++ b/witty_wisterias/backend/user_input_handler.py @@ -29,6 +29,7 @@ def image_to_text(image_base64: str) -> str: with connect("wss://olmocr.allenai.org/api/ws", max_size=10 * 1024 * 1024) as websocket: # Removing the "data:image/jpeg;base64," prefix if it exists image_base64 = image_base64.removeprefix("data:image/jpeg;base64,") + # Sending the base64 image to the WebSocket server websocket.send(json.dumps({"fileChunk": image_base64})) websocket.send(json.dumps({"endOfFile": True})) diff --git a/witty_wisterias/frontend/states/chat_state.py b/witty_wisterias/frontend/states/chat_state.py index 4935c579..8bc18a8d 100644 --- a/witty_wisterias/frontend/states/chat_state.py +++ b/witty_wisterias/frontend/states/chat_state.py @@ -23,10 +23,12 @@ class ChatState(WebcamStateMixin, rx.State): # Tos Accepted (Note: We need to use a string here because LocalStorage does not support booleans) tos_accepted: str = rx.LocalStorage("False", name="tos_accepted", sync=True) + # List of Messages messages: list[MessageState] = rx.field(default_factory=list) # We need to store our own private messages in LocalStorage, as we cannot decrypt them from the Database own_private_messages: str = rx.LocalStorage("[]", name="private_messages", sync=True) + # Chat Partners chat_partners: list[str] = rx.field(default_factory=list) # Current Selected Chat @@ -36,6 +38,7 @@ class ChatState(WebcamStateMixin, rx.State): user_id: str = rx.LocalStorage("", name="user_id", sync=True) user_name: str = rx.LocalStorage("", name="user_name", sync=True) user_profile_image: str = rx.LocalStorage("", name="user_profile_image", sync=True) + # Own Signing key and Others Verify Keys for Global Chat signing_key: str = rx.LocalStorage("", name="signing_key", sync=True) verify_keys_storage: str = rx.LocalStorage("{}", name="verify_keys_storage", sync=True) @@ -237,6 +240,7 @@ async def send_public_image(self, form_data: dict[str, Any]) -> AsyncGenerator[N sender_username=self.user_name, sender_profile_image=self.user_profile_image, ) + # To not block the UI thread, we run this in an executor before the async with self. loop = asyncio.get_running_loop() # Send the Message without blocking the UI thread. @@ -303,6 +307,7 @@ async def send_private_text(self, form_data: dict[str, Any]) -> AsyncGenerator[N sender_username=self.user_name, sender_profile_image=self.user_profile_image, ) + # To not block the UI thread, we run this in an executor before the async with self. loop = asyncio.get_running_loop() # Send the Message without blocking the UI thread. @@ -351,6 +356,7 @@ async def send_private_image(self, form_data: dict[str, Any]) -> AsyncGenerator[ timestamp=message_timestamp, ) self.messages.append(chat_message) + # Also append to own private messages, as we cannot decrypt them from the Database own_private_messages_json = json.loads(self.own_private_messages) own_private_messages_json.append(chat_message.to_dict()) @@ -371,6 +377,7 @@ async def send_private_image(self, form_data: dict[str, Any]) -> AsyncGenerator[ sender_username=self.user_name, sender_profile_image=self.user_profile_image, ) + # To not block the UI thread, we run this in an executor before the async with self. loop = asyncio.get_running_loop() # Send the Message without blocking the UI thread. @@ -391,6 +398,7 @@ async def check_messages(self) -> None: backend_private_message_formats = await loop.run_in_executor( None, Backend.read_private_messages, self.user_id, self.private_key ) + async with self: # Push Verify and Public Keys to the LocalStorage for user_id, verify_key in verify_keys.items(): From 359e8f092b541d369f32d9cbb0a961a0ff64bfda Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:16:42 +0200 Subject: [PATCH 55/56] Readme Text Documentation --- README.md | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 935351a1..2c11e3ca 100644 --- a/README.md +++ b/README.md @@ -1 +1,114 @@ -# Witty Wisterias +

+ 💩 ShitChat: The best worst Chat you've ever used +

+ +**Witty Wisterias** Python Discord Summer CodeJam 2025 project. +The technology is **Python in the Browser**, the theme is **Wrong Tool for the Job**, and our chosen framework +is [Reflex](https://github.com/reflex-dev/reflex). + +--- + +Are you searching for a 100% private, secure, end2end-encrypted chat application, hosted for free publicly? +
+And do you want it to be hosted in Images on a Free Image Hoster, with the Chat Inputs beeing to wrong way arround? +
+Then look no further, because **ShitChat** is the ~~wrong~~ right tool for you! + + +## Naming +Wondered where the name **ShitChat** comes from? +The name is a play on words, coming from [`Chit Chat`](https://dictionary.cambridge.org/dictionary/english/chit-chat) and Shit, as the Chat App is quite shitty... + +## Getting Started + +1. Clone the repository: + ```shell + git clone https://github.com/WittyWisterias/WittyWisterias/ + cd WittyWisterias + ``` +2. Install the dependencies: + ```shell + pip install -r requirements.txt + ``` +3. Start a local Reflex Server: + ```shell + cd witty_wisterias + reflex run + ``` +4. Allow Python access to your Webcam if prompted. +5. Open your browser and navigate to http://localhost:3000. +6. Chat! + +## Video Presentation +[Video Presentation / Description](https://streamable.com/e/ag0v4j#) + +## Wrong Tool for the Job +
+ Theme Aspects ✅ + +- Having to hold Handwritten Messages in front of your Webcam to send Text Messages +- Having to describe your image to send Image Messages +- Hosting the complete Chat Database in one Image File on an Free Image Hoster + +Note: We searched for wrong tools for the Cryptography, with one of more promising candidates being regex based +encryption, but we decided to not sacrifice user security and privacy for the sake of the theme. + +
+ +## Features +
+ Features Summary 💡 + +- Free and Open Database hosted as Image Files on [freeimghost.net](https://freeimghost.net/search/images/?q=ShitChat) +- 100% Private and Secure, no Backend Server needed +- Full Chat History stored in the Image Stack +- Creation of a Unique UserID, Sign-Verify / Public-Private Key Pair on first Enter +- Automatic Sharing of Verify and Public Keys in the Image Stack +- Signed Messages in Public Chat to protect against impersonifications +- End2End Encryption of Private Messages +- Storage of own Private Messages in Local Storage, as they cannot be self-decrypted +- Storage of others Verify / Public Keys in LocalStorage to protect against Image Stack/Chat History Swap Attacks +- Customization of your User Name and Profile Picture +- Sending Text via Webcam OCR using [olmocr.allenai.org](https://olmocr.allenai.org/) +- Sending Images via Image Generation using [pollinations.ai](https://pollinations.ai/) + +
+ +## Privacy and Security +
+ Information about your Privacy 🔐 + +- **No guarantee of Privacy or Security**: Even though **ShitChat** uses common, SOTA compliant cryptographic algorithms, the Chat app has not been audited for security. Use at your own risk. +- **No End2End Encryption of Public Messages**: Public Messages are, as the name says, public. They however are signed to protect against impersonification. +- **No guarantee of UserInput Privacy**: We use [olmocr.allenai.org](https://olmocr.allenai.org/) for OCR and [pollinations.ai](https://pollinations.ai/) for Image Generation. Only the results of these APIs will be shared in the Chat (so your webcam image will not be shared in the Chat). We cannot guarantee the Privacy of your Data shared with these APIs, however they do have strict Privacy Policies. +- **Use UserID to verify Identities**: The UserID is your only way to verify the identity of a user. Username and Profile Image can be changed at any time, and duplicated by another user. +- **Reliant on Local Storage**: **ShitChat** is only secure against "Database Swap" attacks as long as you do not clear your browser's local storage or switch browsers. If you do so, you will be at the risk of an Image Stack/Chat History Swap Attack. +- **Open to Everyone**: There is no friend feature, you can be Private Messaged by anyone. + +
+ + +## Preview Images +
+ Preview Images 📸 + +### See the latest Encoded ChatApp Message Stack: +https://freeimghost.net/search/images/?q=ShitChat + +#### Public Chat: + +#### Private Chat: + +#### Text Message Form: + +#### Image Message Form: + +
+ +# Credits +This project was created by (in order of contributed LOC): + +| [Vinyzu](https://github.com/Vinyzu) | [erri4](https://github.com/erri4) | [Ben Gilbert](https://github.com/bensgilbert) | [Pedro Alves](https://github.com/pedro-alvesjr) | [Tails5000](https://github.com/Tails5000) | +|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------| +| [vinyzu](https://github.com/vinyzu) | [erri4](https://github.com/erri4) | [bensgilbert](https://github.com/bensgilbert) | [pedro-alvesjr](https://github.com/pedro-alvesjr) | [Tails5000](https://github.com/Tails5000) | +| Chat App | Database, Backend | First Frontend | Message Format | OCR Research | From cc5b0ea5df3940617218fe9f3cda2665ec1b47cd Mon Sep 17 00:00:00 2001 From: Vinyzu <50874994+Vinyzu@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:23:56 +0200 Subject: [PATCH 56/56] Readme Media Documentation --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c11e3ca..d1d4d626 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,11 @@ The name is a play on words, coming from [`Chit Chat`](https://dictionary.cambri 6. Chat! ## Video Presentation -[Video Presentation / Description](https://streamable.com/e/ag0v4j#) + +https://github.com/user-attachments/assets/34d2b84e-76cb-40d1-8aca-c71dcacdc9dd + +(Unmute for some grooves) + ## Wrong Tool for the Job
@@ -96,12 +100,18 @@ encryption, but we decided to not sacrifice user security and privacy for the sa https://freeimghost.net/search/images/?q=ShitChat #### Public Chat: +Public Chat #### Private Chat: +Private Chat #### Text Message Form: +Text Message Form + #### Image Message Form: +Image Message Form +