Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0605b0ba | ||
|
|
e240f8aa59 | ||
|
|
a0094b4188 | ||
|
|
eaf8aef993 | ||
|
|
8d52ab36aa | ||
|
|
4dc48ed3ab | ||
|
|
67bc7fb079 | ||
|
|
5df3cd130f | ||
|
|
5bcd63c427 | ||
|
|
74bdd15bc5 | ||
|
|
d497210fab | ||
|
|
0c20afa5ee | ||
|
|
d736659b2a | ||
|
|
56be74ccc9 | ||
|
|
0496369b05 | ||
|
|
9c047850ff | ||
|
|
390b938c05 | ||
|
|
ee474774e1 | ||
|
|
5bfa479236 | ||
|
|
5ab0a40b03 | ||
|
|
ab4764f521 | ||
|
|
fb5e5ff404 | ||
|
|
314c23f4c9 | ||
|
|
78fbf36c38 | ||
|
|
8568e61cf0 | ||
|
|
7a26378a09 |
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
+84
@@ -0,0 +1,84 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: paramiko
|
||||||
|
Version: 5.0.0
|
||||||
|
Summary: SSH2 protocol library
|
||||||
|
Author-email: Jeff Forcier <jeff@bitprophet.org>
|
||||||
|
License-Expression: LGPL-2.1
|
||||||
|
Project-URL: Docs, https://docs.paramiko.org
|
||||||
|
Project-URL: Source, https://github.com/paramiko/paramiko
|
||||||
|
Project-URL: Changelog, https://www.paramiko.org/changelog.html
|
||||||
|
Project-URL: CI, https://app.circleci.com/pipelines/github/paramiko/paramiko
|
||||||
|
Project-URL: Issues, https://github.com/paramiko/paramiko/issues
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: Operating System :: OS Independent
|
||||||
|
Classifier: Topic :: Internet
|
||||||
|
Classifier: Topic :: Security :: Cryptography
|
||||||
|
Classifier: Programming Language :: Python
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3 :: Only
|
||||||
|
Classifier: Programming Language :: Python :: 3.9
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Requires-Python: >=3.9
|
||||||
|
Description-Content-Type: text/x-rst
|
||||||
|
License-File: LICENSE
|
||||||
|
Requires-Dist: bcrypt>=3.2
|
||||||
|
Requires-Dist: cryptography>=3.3
|
||||||
|
Requires-Dist: invoke>=2.0
|
||||||
|
Requires-Dist: pynacl>=1.5
|
||||||
|
Dynamic: license-file
|
||||||
|
|
||||||
|
|version| |python| |license| |ci| |coverage|
|
||||||
|
|
||||||
|
.. |version| image:: https://img.shields.io/pypi/v/paramiko
|
||||||
|
:target: https://pypi.org/project/paramiko/
|
||||||
|
:alt: PyPI - Package Version
|
||||||
|
.. |python| image:: https://img.shields.io/pypi/pyversions/paramiko
|
||||||
|
:target: https://pypi.org/project/paramiko/
|
||||||
|
:alt: PyPI - Python Version
|
||||||
|
.. |license| image:: https://img.shields.io/pypi/l/paramiko
|
||||||
|
:target: https://github.com/paramiko/paramiko/blob/main/LICENSE
|
||||||
|
:alt: PyPI - License
|
||||||
|
.. |ci| image:: https://img.shields.io/circleci/build/github/paramiko/paramiko/main
|
||||||
|
:target: https://app.circleci.com/pipelines/github/paramiko/paramiko
|
||||||
|
:alt: CircleCI
|
||||||
|
.. |coverage| image:: https://img.shields.io/codecov/c/gh/paramiko/paramiko
|
||||||
|
:target: https://app.codecov.io/gh/paramiko/paramiko
|
||||||
|
:alt: Codecov
|
||||||
|
|
||||||
|
Welcome to Paramiko!
|
||||||
|
====================
|
||||||
|
|
||||||
|
Paramiko is a pure-Python [#]_ implementation of the SSHv2 protocol [#]_,
|
||||||
|
providing both client and server functionality. It provides the foundation for
|
||||||
|
the high-level SSH library `Fabric <https://fabfile.org>`_, which is what we
|
||||||
|
recommend you use for common client use-cases such as running remote shell
|
||||||
|
commands or transferring files.
|
||||||
|
|
||||||
|
Direct use of Paramiko itself is only intended for users who need
|
||||||
|
advanced/low-level primitives or want to run an in-Python sshd.
|
||||||
|
|
||||||
|
For installation information, changelogs, FAQs and similar, please visit `our
|
||||||
|
main project website <https://paramiko.org>`_; for API details, see `the
|
||||||
|
versioned docs <https://docs.paramiko.org>`_. Additionally, the project
|
||||||
|
maintainer keeps a `roadmap <http://bitprophet.org/projects#roadmap>`_ on his
|
||||||
|
personal site.
|
||||||
|
|
||||||
|
.. [#]
|
||||||
|
Paramiko relies on `cryptography <https://cryptography.io>`_ for crypto
|
||||||
|
functionality, which makes use of C and Rust extensions but has many
|
||||||
|
precompiled options available. See `our installation page
|
||||||
|
<https://www.paramiko.org/installing.html>`_ for details.
|
||||||
|
|
||||||
|
.. [#]
|
||||||
|
OpenSSH's RFC specification page is a fantastic resource and collection of
|
||||||
|
links that we won't bother replicating here:
|
||||||
|
https://www.openssh.com/specs.html
|
||||||
|
|
||||||
|
OpenSSH itself also happens to be our primary reference implementation:
|
||||||
|
when in doubt, we consult how they do things, unless there are good reasons
|
||||||
|
not to. There are always some gaps, but we do our best to reconcile them
|
||||||
|
when possible.
|
||||||
Binary file not shown.
+275
@@ -0,0 +1,275 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: PyNaCl
|
||||||
|
Version: 1.6.2
|
||||||
|
Summary: Python binding to the Networking and Cryptography (NaCl) library
|
||||||
|
Home-page: https://github.com/pyca/pynacl/
|
||||||
|
Author-email: The PyNaCl developers <cryptography-dev@python.org>
|
||||||
|
License: Apache-2.0
|
||||||
|
Project-URL: Homepage, https://github.com/pyca/pynacl
|
||||||
|
Project-URL: Bug Tracker, https://github.com/pyca/pynacl/issues
|
||||||
|
Project-URL: Documentation, https://pynacl.readthedocs.io
|
||||||
|
Classifier: License :: OSI Approved :: Apache Software License
|
||||||
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
||||||
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
||||||
|
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3.8
|
||||||
|
Classifier: Programming Language :: Python :: 3.9
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: Programming Language :: Python :: 3.14
|
||||||
|
Requires-Python: >=3.8
|
||||||
|
License-File: LICENSE
|
||||||
|
License-File: licenses/LICENSE.libsodium.txt
|
||||||
|
Requires-Dist: cffi>=1.4.1; platform_python_implementation != "PyPy" and python_version < "3.9"
|
||||||
|
Requires-Dist: cffi>=2.0.0; platform_python_implementation != "PyPy" and python_version >= "3.9"
|
||||||
|
Provides-Extra: tests
|
||||||
|
Requires-Dist: pytest>=7.4.0; extra == "tests"
|
||||||
|
Requires-Dist: pytest-cov>=2.10.1; extra == "tests"
|
||||||
|
Requires-Dist: pytest-xdist>=3.5.0; extra == "tests"
|
||||||
|
Requires-Dist: hypothesis>=3.27.0; extra == "tests"
|
||||||
|
Provides-Extra: docs
|
||||||
|
Requires-Dist: sphinx<7; extra == "docs"
|
||||||
|
Requires-Dist: sphinx_rtd_theme; extra == "docs"
|
||||||
|
Dynamic: description
|
||||||
|
Dynamic: home-page
|
||||||
|
Dynamic: license-file
|
||||||
|
|
||||||
|
===============================================
|
||||||
|
PyNaCl: Python binding to the libsodium library
|
||||||
|
===============================================
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/pypi/v/pynacl.svg
|
||||||
|
:target: https://pypi.org/project/PyNaCl/
|
||||||
|
:alt: Latest Version
|
||||||
|
|
||||||
|
PyNaCl is a Python binding to `libsodium`_, which is a fork of the
|
||||||
|
`Networking and Cryptography library`_. These libraries have a stated goal of
|
||||||
|
improving usability, security and speed. It supports Python 3.8+ as well as
|
||||||
|
PyPy 3.
|
||||||
|
|
||||||
|
.. _libsodium: https://github.com/jedisct1/libsodium
|
||||||
|
.. _Networking and Cryptography library: https://nacl.cr.yp.to/
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
|
||||||
|
* Digital signatures
|
||||||
|
* Secret-key encryption
|
||||||
|
* Public-key encryption
|
||||||
|
* Hashing and message authentication
|
||||||
|
* Password based key derivation and password hashing
|
||||||
|
|
||||||
|
`Changelog`_
|
||||||
|
------------
|
||||||
|
|
||||||
|
.. _Changelog: https://pynacl.readthedocs.io/en/latest/changelog/
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
Binary wheel install
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
PyNaCl ships as a binary wheel on macOS, Windows and Linux ``manylinux1`` [#many]_ ,
|
||||||
|
so all dependencies are included. Make sure you have an up-to-date pip
|
||||||
|
and run:
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
|
$ pip install pynacl
|
||||||
|
|
||||||
|
Faster wheel build
|
||||||
|
------------------
|
||||||
|
|
||||||
|
You can define the environment variable ``LIBSODIUM_MAKE_ARGS`` to pass arguments to ``make``
|
||||||
|
and enable `parallelization`_:
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
|
$ LIBSODIUM_MAKE_ARGS=-j4 pip install pynacl
|
||||||
|
|
||||||
|
Linux source build
|
||||||
|
------------------
|
||||||
|
|
||||||
|
PyNaCl relies on `libsodium`_, a portable C library. A copy is bundled
|
||||||
|
with PyNaCl so to install you can run:
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
|
$ pip install pynacl
|
||||||
|
|
||||||
|
If you'd prefer to use the version of ``libsodium`` provided by your
|
||||||
|
distribution, you can disable the bundled copy during install by running:
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
|
$ SODIUM_INSTALL=system pip install pynacl
|
||||||
|
|
||||||
|
.. warning:: Usage of the legacy ``easy_install`` command provided by setuptools
|
||||||
|
is generally discouraged, and is completely unsupported in PyNaCl's case.
|
||||||
|
|
||||||
|
.. _parallelization: https://www.gnu.org/software/make/manual/html_node/Parallel.html
|
||||||
|
|
||||||
|
.. _libsodium: https://github.com/jedisct1/libsodium
|
||||||
|
|
||||||
|
.. [#many] `manylinux1 wheels <https://www.python.org/dev/peps/pep-0513/>`_
|
||||||
|
are built on a baseline linux environment based on Centos 5.11
|
||||||
|
and should work on most x86 and x86_64 glibc based linux environments.
|
||||||
|
|
||||||
|
GNU Make (gmake) may be required for newer versions of PyNaCl. You can
|
||||||
|
set the binary to use by setting the ``MAKE`` environment variable:
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
|
$ MAKE=gmake pip install pynacl
|
||||||
|
|
||||||
|
Changelog
|
||||||
|
=========
|
||||||
|
|
||||||
|
1.6.2 (2026-01-01)
|
||||||
|
------------------
|
||||||
|
* Updated ``libsodium`` to 1.0.20-stable (2025-12-31 build) to resolve
|
||||||
|
``CVE-2025-69277``.
|
||||||
|
|
||||||
|
1.6.1 (2025-11-10)
|
||||||
|
------------------
|
||||||
|
* The ``MAKE`` environment variable can now be used to specify the ``make``
|
||||||
|
binary that should be used in the build process.
|
||||||
|
|
||||||
|
1.6.0 (2025-09-11)
|
||||||
|
------------------
|
||||||
|
* **BACKWARDS INCOMPATIBLE:** Removed support for Python 3.6 and 3.7.
|
||||||
|
* Added support for the low level AEAD AES bindings.
|
||||||
|
* Added support for ``crypto_core_ed25519_from_uniform``.
|
||||||
|
* Update ``libsodium`` to 1.0.20-stable (2025-08-27 build).
|
||||||
|
* Added support for free-threaded Python 3.14.
|
||||||
|
* Added support for Windows on ARM wheels.
|
||||||
|
|
||||||
|
1.5.0 (2022-01-07)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* **BACKWARDS INCOMPATIBLE:** Removed support for Python 2.7 and Python 3.5.
|
||||||
|
* **BACKWARDS INCOMPATIBLE:** We no longer distribute ``manylinux1``
|
||||||
|
wheels.
|
||||||
|
* Added ``manylinux2014``, ``manylinux_2_24``, ``musllinux``, and macOS
|
||||||
|
``universal2`` wheels (the latter supports macOS ``arm64``).
|
||||||
|
* Update ``libsodium`` to 1.0.18-stable (July 25, 2021 release).
|
||||||
|
* Add inline type hints.
|
||||||
|
|
||||||
|
1.4.0 (2020-05-25)
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* Update ``libsodium`` to 1.0.18.
|
||||||
|
* **BACKWARDS INCOMPATIBLE:** We no longer distribute 32-bit ``manylinux1``
|
||||||
|
wheels. Continuing to produce them was a maintenance burden.
|
||||||
|
* Added support for Python 3.8, and removed support for Python 3.4.
|
||||||
|
* Add low level bindings for extracting the seed and the public key
|
||||||
|
from crypto_sign_ed25519 secret key
|
||||||
|
* Add low level bindings for deterministic random generation.
|
||||||
|
* Add ``wheel`` and ``setuptools`` setup_requirements in ``setup.py`` (#485)
|
||||||
|
* Fix checks on very slow builders (#481, #495)
|
||||||
|
* Add low-level bindings to ed25519 arithmetic functions
|
||||||
|
* Update low-level blake2b state implementation
|
||||||
|
* Fix wrong short-input behavior of SealedBox.decrypt() (#517)
|
||||||
|
* Raise CryptPrefixError exception instead of InvalidkeyError when trying
|
||||||
|
to check a password against a verifier stored in a unknown format (#519)
|
||||||
|
* Add support for minimal builds of libsodium. Trying to call functions
|
||||||
|
not available in a minimal build will raise an UnavailableError
|
||||||
|
exception. To compile a minimal build of the bundled libsodium, set
|
||||||
|
the SODIUM_INSTALL_MINIMAL environment variable to any non-empty
|
||||||
|
string (e.g. ``SODIUM_INSTALL_MINIMAL=1``) for setup.
|
||||||
|
|
||||||
|
1.3.0 2018-09-26
|
||||||
|
----------------
|
||||||
|
|
||||||
|
* Added support for Python 3.7.
|
||||||
|
* Update ``libsodium`` to 1.0.16.
|
||||||
|
* Run and test all code examples in PyNaCl docs through sphinx's
|
||||||
|
doctest builder.
|
||||||
|
* Add low-level bindings for chacha20-poly1305 AEAD constructions.
|
||||||
|
* Add low-level bindings for the chacha20-poly1305 secretstream constructions.
|
||||||
|
* Add low-level bindings for ed25519ph pre-hashed signing construction.
|
||||||
|
* Add low-level bindings for constant-time increment and addition
|
||||||
|
on fixed-precision big integers represented as little-endian
|
||||||
|
byte sequences.
|
||||||
|
* Add low-level bindings for the ISO/IEC 7816-4 compatible padding API.
|
||||||
|
* Add low-level bindings for libsodium's crypto_kx... key exchange
|
||||||
|
construction.
|
||||||
|
* Set hypothesis deadline to None in tests/test_pwhash.py to avoid
|
||||||
|
incorrect test failures on slower processor architectures. GitHub
|
||||||
|
issue #370
|
||||||
|
|
||||||
|
1.2.1 - 2017-12-04
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* Update hypothesis minimum allowed version.
|
||||||
|
* Infrastructure: add proper configuration for readthedocs builder
|
||||||
|
runtime environment.
|
||||||
|
|
||||||
|
1.2.0 - 2017-11-01
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* Update ``libsodium`` to 1.0.15.
|
||||||
|
* Infrastructure: add jenkins support for automatic build of
|
||||||
|
``manylinux1`` binary wheels
|
||||||
|
* Added support for ``SealedBox`` construction.
|
||||||
|
* Added support for ``argon2i`` and ``argon2id`` password hashing constructs
|
||||||
|
and restructured high-level password hashing implementation to expose
|
||||||
|
the same interface for all hashers.
|
||||||
|
* Added support for 128 bit ``siphashx24`` variant of ``siphash24``.
|
||||||
|
* Added support for ``from_seed`` APIs for X25519 key pair generation.
|
||||||
|
* Dropped support for Python 3.3.
|
||||||
|
|
||||||
|
1.1.2 - 2017-03-31
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* reorder link time library search path when using bundled
|
||||||
|
libsodium
|
||||||
|
|
||||||
|
1.1.1 - 2017-03-15
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* Fixed a circular import bug in ``nacl.utils``.
|
||||||
|
|
||||||
|
1.1.0 - 2017-03-14
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* Dropped support for Python 2.6.
|
||||||
|
* Added ``shared_key()`` method on ``Box``.
|
||||||
|
* You can now pass ``None`` to ``nonce`` when encrypting with ``Box`` or
|
||||||
|
``SecretBox`` and it will automatically generate a random nonce.
|
||||||
|
* Added support for ``siphash24``.
|
||||||
|
* Added support for ``blake2b``.
|
||||||
|
* Added support for ``scrypt``.
|
||||||
|
* Update ``libsodium`` to 1.0.11.
|
||||||
|
* Default to the bundled ``libsodium`` when compiling.
|
||||||
|
* All raised exceptions are defined mixing-in
|
||||||
|
``nacl.exceptions.CryptoError``
|
||||||
|
|
||||||
|
1.0.1 - 2016-01-24
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* Fix an issue with absolute paths that prevented the creation of wheels.
|
||||||
|
|
||||||
|
1.0 - 2016-01-23
|
||||||
|
----------------
|
||||||
|
|
||||||
|
* PyNaCl has been ported to use the new APIs available in cffi 1.0+.
|
||||||
|
Due to this change we no longer support PyPy releases older than 2.6.
|
||||||
|
* Python 3.2 support has been dropped.
|
||||||
|
* Functions to convert between Ed25519 and Curve25519 keys have been added.
|
||||||
|
|
||||||
|
0.3.0 - 2015-03-04
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* The low-level API (`nacl.c.*`) has been changed to match the
|
||||||
|
upstream NaCl C/C++ conventions (as well as those of other NaCl bindings).
|
||||||
|
The order of arguments and return values has changed significantly. To
|
||||||
|
avoid silent failures, `nacl.c` has been removed, and replaced with
|
||||||
|
`nacl.bindings` (with the new argument ordering). If you have code which
|
||||||
|
calls these functions (e.g. `nacl.c.crypto_box_keypair()`), you must review
|
||||||
|
the new docstrings and update your code/imports to match the new
|
||||||
|
conventions.
|
||||||
Binary file not shown.
+74
@@ -0,0 +1,74 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: invoke
|
||||||
|
Version: 3.0.3
|
||||||
|
Summary: Pythonic task execution
|
||||||
|
Author-email: Jeff Forcier <jeff@bitprophet.org>
|
||||||
|
License-Expression: BSD-2-Clause
|
||||||
|
Project-URL: Docs, https://docs.pyinvoke.org
|
||||||
|
Project-URL: Source, https://github.com/pyinvoke/invoke
|
||||||
|
Project-URL: Changelog, https://www.pyinvoke.org/changelog.html
|
||||||
|
Project-URL: CI, https://app.circleci.com/pipelines/github/pyinvoke/invoke
|
||||||
|
Project-URL: Issues, https://github.com/pyinvoke/invoke/issues
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: Environment :: Console
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: Intended Audience :: System Administrators
|
||||||
|
Classifier: Operating System :: POSIX
|
||||||
|
Classifier: Operating System :: Unix
|
||||||
|
Classifier: Operating System :: MacOS :: MacOS X
|
||||||
|
Classifier: Operating System :: Microsoft :: Windows
|
||||||
|
Classifier: Programming Language :: Python
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3 :: Only
|
||||||
|
Classifier: Programming Language :: Python :: 3.9
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: Programming Language :: Python :: 3.14
|
||||||
|
Classifier: Topic :: Software Development
|
||||||
|
Classifier: Topic :: Software Development :: Build Tools
|
||||||
|
Classifier: Topic :: Software Development :: Libraries
|
||||||
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||||
|
Classifier: Topic :: System :: Software Distribution
|
||||||
|
Classifier: Topic :: System :: Systems Administration
|
||||||
|
Requires-Python: >=3.9
|
||||||
|
Description-Content-Type: text/x-rst
|
||||||
|
License-File: LICENSE
|
||||||
|
Dynamic: license-file
|
||||||
|
|
||||||
|
|version| |python| |license| |ci| |coverage|
|
||||||
|
|
||||||
|
.. |version| image:: https://img.shields.io/pypi/v/invoke
|
||||||
|
:target: https://pypi.org/project/invoke/
|
||||||
|
:alt: PyPI - Package Version
|
||||||
|
.. |python| image:: https://img.shields.io/pypi/pyversions/invoke
|
||||||
|
:target: https://pypi.org/project/invoke/
|
||||||
|
:alt: PyPI - Python Version
|
||||||
|
.. |license| image:: https://img.shields.io/pypi/l/invoke
|
||||||
|
:target: https://github.com/pyinvoke/invoke/blob/main/LICENSE
|
||||||
|
:alt: PyPI - License
|
||||||
|
.. |ci| image:: https://img.shields.io/circleci/build/github/pyinvoke/invoke/main
|
||||||
|
:target: https://app.circleci.com/pipelines/github/pyinvoke/invoke
|
||||||
|
:alt: CircleCI
|
||||||
|
.. |coverage| image:: https://img.shields.io/codecov/c/gh/pyinvoke/invoke
|
||||||
|
:target: https://app.codecov.io/gh/pyinvoke/invoke
|
||||||
|
:alt: Codecov
|
||||||
|
|
||||||
|
Welcome to Invoke!
|
||||||
|
==================
|
||||||
|
|
||||||
|
Invoke is a Python library for managing shell-oriented subprocesses and
|
||||||
|
organizing executable Python code into CLI-invokable tasks. It draws
|
||||||
|
inspiration from various sources (``make``/``rake``, Fabric 1.x, etc) to arrive
|
||||||
|
at a powerful & clean feature set.
|
||||||
|
|
||||||
|
To find out what's new in this version of Invoke, please see `the changelog
|
||||||
|
<https://pyinvoke.org/changelog.html#{}>`_.
|
||||||
|
|
||||||
|
For a high level introduction, including example code, please see `our main
|
||||||
|
project website <https://pyinvoke.org>`_; or for detailed API docs, see `the
|
||||||
|
versioned API website <https://docs.pyinvoke.org>`_.
|
||||||
|
|
||||||
|
The project maintainer keeps a `roadmap
|
||||||
|
<https://bitprophet.org/projects#roadmap>`_ on his website.
|
||||||
Binary file not shown.
+67
@@ -0,0 +1,67 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: cffi
|
||||||
|
Version: 2.1.1
|
||||||
|
Summary: Foreign Function Interface for Python calling C code.
|
||||||
|
Author: Armin Rigo, Maciej Fijalkowski
|
||||||
|
Maintainer: Matt Davis, Matt Clay
|
||||||
|
License-Expression: MIT-0
|
||||||
|
Project-URL: Documentation, https://cffi.readthedocs.io/
|
||||||
|
Project-URL: Changelog, https://cffi.readthedocs.io/en/latest/whatsnew.html
|
||||||
|
Project-URL: Download, https://github.com/python-cffi/cffi/releases
|
||||||
|
Project-URL: Contact, https://groups.google.com/forum/#!forum/python-cffi
|
||||||
|
Project-URL: Source Code, https://github.com/python-cffi/cffi
|
||||||
|
Project-URL: Issue Tracker, https://github.com/python-cffi/cffi/issues
|
||||||
|
Classifier: Programming Language :: Python
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: Programming Language :: Python :: 3.14
|
||||||
|
Classifier: Programming Language :: Python :: 3.15
|
||||||
|
Classifier: Programming Language :: Python :: Free Threading :: 2 - Beta
|
||||||
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
||||||
|
Requires-Python: >=3.10
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
License-File: LICENSE
|
||||||
|
Requires-Dist: pycparser; implementation_name != "PyPy"
|
||||||
|
Dynamic: license-file
|
||||||
|
|
||||||
|
[](https://github.com/python-cffi/cffi/actions/workflows/ci.yaml?query=branch%3Amain++)
|
||||||
|
[](https://pypi.org/project/cffi)
|
||||||
|
[][Documentation]
|
||||||
|
|
||||||
|
|
||||||
|
CFFI
|
||||||
|
====
|
||||||
|
|
||||||
|
Foreign Function Interface for Python calling C code.
|
||||||
|
|
||||||
|
Please see the [Documentation] or uncompiled in the `doc/` subdirectory.
|
||||||
|
|
||||||
|
Download
|
||||||
|
--------
|
||||||
|
|
||||||
|
[Download page](https://github.com/python-cffi/cffi/releases)
|
||||||
|
|
||||||
|
Source Code
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Source code is publicly available on
|
||||||
|
[GitHub](https://github.com/python-cffi/cffi).
|
||||||
|
|
||||||
|
Contact
|
||||||
|
-------
|
||||||
|
|
||||||
|
[Mailing list](https://groups.google.com/forum/#!forum/python-cffi)
|
||||||
|
|
||||||
|
Testing/development tips
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
After `git clone` or `wget && tar`, we will get a directory called `cffi` or `cffi-x.x.x`. We call it `repo-directory`. To run tests under CPython, run the following in the `repo-directory`:
|
||||||
|
|
||||||
|
pip install pytest
|
||||||
|
pip install -e . # editable install of CFFI for local development
|
||||||
|
pytest src/c/ testing/
|
||||||
|
|
||||||
|
[Documentation]: http://cffi.readthedocs.org/
|
||||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
+343
@@ -0,0 +1,343 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: bcrypt
|
||||||
|
Version: 5.0.0
|
||||||
|
Summary: Modern password hashing for your software and your servers
|
||||||
|
Author-email: The Python Cryptographic Authority developers <cryptography-dev@python.org>
|
||||||
|
License: Apache-2.0
|
||||||
|
Project-URL: homepage, https://github.com/pyca/bcrypt/
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: License :: OSI Approved :: Apache Software License
|
||||||
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
||||||
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3 :: Only
|
||||||
|
Classifier: Programming Language :: Python :: 3.8
|
||||||
|
Classifier: Programming Language :: Python :: 3.9
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: Programming Language :: Python :: 3.14
|
||||||
|
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
||||||
|
Requires-Python: >=3.8
|
||||||
|
Description-Content-Type: text/x-rst
|
||||||
|
License-File: LICENSE
|
||||||
|
Provides-Extra: tests
|
||||||
|
Requires-Dist: pytest!=3.3.0,>=3.2.1; extra == "tests"
|
||||||
|
Provides-Extra: typecheck
|
||||||
|
Requires-Dist: mypy; extra == "typecheck"
|
||||||
|
Dynamic: license-file
|
||||||
|
|
||||||
|
bcrypt
|
||||||
|
======
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/pypi/v/bcrypt.svg
|
||||||
|
:target: https://pypi.org/project/bcrypt/
|
||||||
|
:alt: Latest Version
|
||||||
|
|
||||||
|
.. image:: https://github.com/pyca/bcrypt/workflows/CI/badge.svg?branch=main
|
||||||
|
:target: https://github.com/pyca/bcrypt/actions?query=workflow%3ACI+branch%3Amain
|
||||||
|
|
||||||
|
Acceptable password hashing for your software and your servers (but you should
|
||||||
|
really use argon2id or scrypt)
|
||||||
|
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
To install bcrypt, simply:
|
||||||
|
|
||||||
|
.. code:: console
|
||||||
|
|
||||||
|
$ pip install bcrypt
|
||||||
|
|
||||||
|
Note that bcrypt should build very easily on Linux provided you have a C
|
||||||
|
compiler and a Rust compiler (the minimum supported Rust version is 1.56.0).
|
||||||
|
|
||||||
|
For Debian and Ubuntu, the following command will ensure that the required dependencies are installed:
|
||||||
|
|
||||||
|
.. code:: console
|
||||||
|
|
||||||
|
$ sudo apt-get install build-essential cargo
|
||||||
|
|
||||||
|
For Fedora and RHEL-derivatives, the following command will ensure that the required dependencies are installed:
|
||||||
|
|
||||||
|
.. code:: console
|
||||||
|
|
||||||
|
$ sudo yum install gcc cargo
|
||||||
|
|
||||||
|
For Alpine, the following command will ensure that the required dependencies are installed:
|
||||||
|
|
||||||
|
.. code:: console
|
||||||
|
|
||||||
|
$ apk add --update musl-dev gcc cargo
|
||||||
|
|
||||||
|
|
||||||
|
Alternatives
|
||||||
|
============
|
||||||
|
|
||||||
|
While bcrypt remains an acceptable choice for password storage, depending on your specific use case you may also want to consider using scrypt (either via `standard library`_ or `cryptography`_) or argon2id via `argon2_cffi`_.
|
||||||
|
|
||||||
|
Changelog
|
||||||
|
=========
|
||||||
|
|
||||||
|
5.0.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Bumped MSRV to 1.74.
|
||||||
|
* Added support for Python 3.14 and free-threaded Python 3.14.
|
||||||
|
* Added support for Windows on ARM.
|
||||||
|
* Passing ``hashpw`` a password longer than 72 bytes now raises a
|
||||||
|
``ValueError``. Previously the password was silently truncated, following the
|
||||||
|
behavior of the original OpenBSD ``bcrypt`` implementation.
|
||||||
|
|
||||||
|
4.3.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Dropped support for Python 3.7.
|
||||||
|
* We now support free-threaded Python 3.13.
|
||||||
|
* We now support PyPy 3.11.
|
||||||
|
* We now publish wheels for free-threaded Python 3.13, for PyPy 3.11 on
|
||||||
|
``manylinux``, and for ARMv7l on ``manylinux``.
|
||||||
|
|
||||||
|
4.2.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Bump Rust dependency versions - this should resolve crashes on Python 3.13
|
||||||
|
free-threaded builds.
|
||||||
|
* We no longer build ``manylinux`` wheels for PyPy 3.9.
|
||||||
|
|
||||||
|
4.2.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Bump Rust dependency versions
|
||||||
|
* Removed the ``BCRYPT_ALLOW_RUST_163`` environment variable.
|
||||||
|
|
||||||
|
4.1.3
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Bump Rust dependency versions
|
||||||
|
|
||||||
|
4.1.2
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Publish both ``py37`` and ``py39`` wheels. This should resolve some errors
|
||||||
|
relating to initializing a module multiple times per process.
|
||||||
|
|
||||||
|
4.1.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Fixed the type signature on the ``kdf`` method.
|
||||||
|
* Fixed packaging bug on Windows.
|
||||||
|
* Fixed incompatibility with passlib package detection assumptions.
|
||||||
|
|
||||||
|
4.1.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Dropped support for Python 3.6.
|
||||||
|
* Bumped MSRV to 1.64. (Note: Rust 1.63 can be used by setting the ``BCRYPT_ALLOW_RUST_163`` environment variable)
|
||||||
|
|
||||||
|
4.0.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
* We now build PyPy ``manylinux`` wheels.
|
||||||
|
* Fixed a bug where passing an invalid ``salt`` to ``checkpw`` could result in
|
||||||
|
a ``pyo3_runtime.PanicException``. It now correctly raises a ``ValueError``.
|
||||||
|
|
||||||
|
4.0.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
* ``bcrypt`` is now implemented in Rust. Users building from source will need
|
||||||
|
to have a Rust compiler available. Nothing will change for users downloading
|
||||||
|
wheels.
|
||||||
|
* We no longer ship ``manylinux2010`` wheels. Users should upgrade to the latest
|
||||||
|
``pip`` to ensure this doesn’t cause issues downloading wheels on their
|
||||||
|
platform. We now ship ``manylinux_2_28`` wheels for users on new enough platforms.
|
||||||
|
* ``NUL`` bytes are now allowed in inputs.
|
||||||
|
|
||||||
|
|
||||||
|
3.2.2
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Fixed packaging of ``py.typed`` files in wheels so that ``mypy`` works.
|
||||||
|
|
||||||
|
3.2.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Added support for compilation on z/OS
|
||||||
|
* The next release of ``bcrypt`` with be 4.0 and it will require Rust at
|
||||||
|
compile time, for users building from source. There will be no additional
|
||||||
|
requirement for users who are installing from wheels. Users on most
|
||||||
|
platforms will be able to obtain a wheel by making sure they have an up to
|
||||||
|
date ``pip``. The minimum supported Rust version will be 1.56.0.
|
||||||
|
* This will be the final release for which we ship ``manylinux2010`` wheels.
|
||||||
|
Going forward the minimum supported manylinux ABI for our wheels will be
|
||||||
|
``manylinux2014``. The vast majority of users will continue to receive
|
||||||
|
``manylinux`` wheels provided they have an up to date ``pip``.
|
||||||
|
|
||||||
|
|
||||||
|
3.2.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Added typehints for library functions.
|
||||||
|
* Dropped support for Python versions less than 3.6 (2.7, 3.4, 3.5).
|
||||||
|
* Shipped ``abi3`` Windows wheels (requires pip >= 20).
|
||||||
|
|
||||||
|
3.1.7
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Set a ``setuptools`` lower bound for PEP517 wheel building.
|
||||||
|
* We no longer distribute 32-bit ``manylinux1`` wheels. Continuing to produce
|
||||||
|
them was a maintenance burden.
|
||||||
|
|
||||||
|
3.1.6
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Added support for compilation on Haiku.
|
||||||
|
|
||||||
|
3.1.5
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Added support for compilation on AIX.
|
||||||
|
* Dropped Python 2.6 and 3.3 support.
|
||||||
|
* Switched to using ``abi3`` wheels for Python 3. If you are not getting a
|
||||||
|
wheel on a compatible platform please upgrade your ``pip`` version.
|
||||||
|
|
||||||
|
3.1.4
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Fixed compilation with mingw and on illumos.
|
||||||
|
|
||||||
|
3.1.3
|
||||||
|
-----
|
||||||
|
* Fixed a compilation issue on Solaris.
|
||||||
|
* Added a warning when using too few rounds with ``kdf``.
|
||||||
|
|
||||||
|
3.1.2
|
||||||
|
-----
|
||||||
|
* Fixed a compile issue affecting big endian platforms.
|
||||||
|
* Fixed invalid escape sequence warnings on Python 3.6.
|
||||||
|
* Fixed building in non-UTF8 environments on Python 2.
|
||||||
|
|
||||||
|
3.1.1
|
||||||
|
-----
|
||||||
|
* Resolved a ``UserWarning`` when used with ``cffi`` 1.8.3.
|
||||||
|
|
||||||
|
3.1.0
|
||||||
|
-----
|
||||||
|
* Added support for ``checkpw``, a convenience method for verifying a password.
|
||||||
|
* Ensure that you get a ``$2y$`` hash when you input a ``$2y$`` salt.
|
||||||
|
* Fixed a regression where ``$2a`` hashes were vulnerable to a wraparound bug.
|
||||||
|
* Fixed compilation under Alpine Linux.
|
||||||
|
|
||||||
|
3.0.0
|
||||||
|
-----
|
||||||
|
* Switched the C backend to code obtained from the OpenBSD project rather than
|
||||||
|
openwall.
|
||||||
|
* Added support for ``bcrypt_pbkdf`` via the ``kdf`` function.
|
||||||
|
|
||||||
|
2.0.0
|
||||||
|
-----
|
||||||
|
* Added support for an adjustible prefix when calling ``gensalt``.
|
||||||
|
* Switched to CFFI 1.0+
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
|
||||||
|
Password Hashing
|
||||||
|
~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Hashing and then later checking that a password matches the previous hashed
|
||||||
|
password is very simple:
|
||||||
|
|
||||||
|
.. code:: pycon
|
||||||
|
|
||||||
|
>>> import bcrypt
|
||||||
|
>>> password = b"super secret password"
|
||||||
|
>>> # Hash a password for the first time, with a randomly-generated salt
|
||||||
|
>>> hashed = bcrypt.hashpw(password, bcrypt.gensalt())
|
||||||
|
>>> # Check that an unhashed password matches one that has previously been
|
||||||
|
>>> # hashed
|
||||||
|
>>> if bcrypt.checkpw(password, hashed):
|
||||||
|
... print("It Matches!")
|
||||||
|
... else:
|
||||||
|
... print("It Does not Match :(")
|
||||||
|
|
||||||
|
KDF
|
||||||
|
~~~
|
||||||
|
|
||||||
|
As of 3.0.0 ``bcrypt`` now offers a ``kdf`` function which does ``bcrypt_pbkdf``.
|
||||||
|
This KDF is used in OpenSSH's newer encrypted private key format.
|
||||||
|
|
||||||
|
.. code:: pycon
|
||||||
|
|
||||||
|
>>> import bcrypt
|
||||||
|
>>> key = bcrypt.kdf(
|
||||||
|
... password=b'password',
|
||||||
|
... salt=b'salt',
|
||||||
|
... desired_key_bytes=32,
|
||||||
|
... rounds=100)
|
||||||
|
|
||||||
|
|
||||||
|
Adjustable Work Factor
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
One of bcrypt's features is an adjustable logarithmic work factor. To adjust
|
||||||
|
the work factor merely pass the desired number of rounds to
|
||||||
|
``bcrypt.gensalt(rounds=12)`` which defaults to 12):
|
||||||
|
|
||||||
|
.. code:: pycon
|
||||||
|
|
||||||
|
>>> import bcrypt
|
||||||
|
>>> password = b"super secret password"
|
||||||
|
>>> # Hash a password for the first time, with a certain number of rounds
|
||||||
|
>>> hashed = bcrypt.hashpw(password, bcrypt.gensalt(14))
|
||||||
|
>>> # Check that a unhashed password matches one that has previously been
|
||||||
|
>>> # hashed
|
||||||
|
>>> if bcrypt.checkpw(password, hashed):
|
||||||
|
... print("It Matches!")
|
||||||
|
... else:
|
||||||
|
... print("It Does not Match :(")
|
||||||
|
|
||||||
|
|
||||||
|
Adjustable Prefix
|
||||||
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Another one of bcrypt's features is an adjustable prefix to let you define what
|
||||||
|
libraries you'll remain compatible with. To adjust this, pass either ``2a`` or
|
||||||
|
``2b`` (the default) to ``bcrypt.gensalt(prefix=b"2b")`` as a bytes object.
|
||||||
|
|
||||||
|
As of 3.0.0 the ``$2y$`` prefix is still supported in ``hashpw`` but deprecated.
|
||||||
|
|
||||||
|
Maximum Password Length
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
The bcrypt algorithm only handles passwords up to 72 characters, any characters
|
||||||
|
beyond that are ignored. To work around this, a common approach is to hash a
|
||||||
|
password with a cryptographic hash (such as ``sha256``) and then base64
|
||||||
|
encode it to prevent NULL byte problems before hashing the result with
|
||||||
|
``bcrypt``:
|
||||||
|
|
||||||
|
.. code:: pycon
|
||||||
|
|
||||||
|
>>> password = b"an incredibly long password" * 10
|
||||||
|
>>> hashed = bcrypt.hashpw(
|
||||||
|
... base64.b64encode(hashlib.sha256(password).digest()),
|
||||||
|
... bcrypt.gensalt()
|
||||||
|
... )
|
||||||
|
|
||||||
|
Compatibility
|
||||||
|
-------------
|
||||||
|
|
||||||
|
This library should be compatible with py-bcrypt and it will run on Python
|
||||||
|
3.8+ (including free-threaded builds), and PyPy 3.
|
||||||
|
|
||||||
|
Security
|
||||||
|
--------
|
||||||
|
|
||||||
|
``bcrypt`` follows the `same security policy as cryptography`_, if you
|
||||||
|
identify a vulnerability, we ask you to contact us privately.
|
||||||
|
|
||||||
|
.. _`same security policy as cryptography`: https://cryptography.io/en/latest/security.html
|
||||||
|
.. _`standard library`: https://docs.python.org/3/library/hashlib.html#hashlib.scrypt
|
||||||
|
.. _`argon2_cffi`: https://argon2-cffi.readthedocs.io
|
||||||
|
.. _`cryptography`: https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/#cryptography.hazmat.primitives.kdf.scrypt.Scrypt
|
||||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
Submodule projects/232801087/myapp updated: ded79a9a9e...a6bf56fafb
Submodule
+1
Submodule projects/78649634/bildirchin added at e109f5d777
Submodule projects/78649634/copykar updated: f8468cdd1a...6d194929e4
Submodule projects/78649634/gasemi_tier updated: d27520ec0f...aca18b7229
Submodule
+1
Submodule projects/78649634/jump-jump added at 0c7b0fb510
@@ -13,6 +13,7 @@ from typing import Optional, Dict, Any, Callable, List, Tuple
|
|||||||
from dataclasses import dataclass, field, asdict
|
from dataclasses import dataclass, field, asdict
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from auth_manager import auth_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -130,6 +131,12 @@ class Project:
|
|||||||
ftp_password: Optional[str] = None
|
ftp_password: Optional[str] = None
|
||||||
ftp_path: str = "/"
|
ftp_path: str = "/"
|
||||||
ftp_tls: bool = False
|
ftp_tls: bool = False
|
||||||
|
ssh_host: Optional[str] = None
|
||||||
|
ssh_port: int = 22
|
||||||
|
ssh_user: Optional[str] = None
|
||||||
|
ssh_password: Optional[str] = None
|
||||||
|
ssh_key: Optional[str] = None
|
||||||
|
ssh_path: str = "/"
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Session:
|
class Session:
|
||||||
@@ -272,6 +279,12 @@ class SessionManager:
|
|||||||
"ftp_password",
|
"ftp_password",
|
||||||
"ftp_path",
|
"ftp_path",
|
||||||
"ftp_tls",
|
"ftp_tls",
|
||||||
|
"ssh_host",
|
||||||
|
"ssh_port",
|
||||||
|
"ssh_user",
|
||||||
|
"ssh_password",
|
||||||
|
"ssh_key",
|
||||||
|
"ssh_path",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if "name" not in clean_p:
|
if "name" not in clean_p:
|
||||||
@@ -360,6 +373,19 @@ class SessionManager:
|
|||||||
"created_at": p_obj.created_at,
|
"created_at": p_obj.created_at,
|
||||||
"description": p_obj.description,
|
"description": p_obj.description,
|
||||||
"conversation_titles": p_obj.conversation_titles,
|
"conversation_titles": p_obj.conversation_titles,
|
||||||
|
"active_branch": p_obj.active_branch,
|
||||||
|
"ftp_host": p_obj.ftp_host,
|
||||||
|
"ftp_port": p_obj.ftp_port,
|
||||||
|
"ftp_user": p_obj.ftp_user,
|
||||||
|
"ftp_password": p_obj.ftp_password,
|
||||||
|
"ftp_path": p_obj.ftp_path,
|
||||||
|
"ftp_tls": p_obj.ftp_tls,
|
||||||
|
"ssh_host": p_obj.ssh_host,
|
||||||
|
"ssh_port": p_obj.ssh_port,
|
||||||
|
"ssh_user": p_obj.ssh_user,
|
||||||
|
"ssh_password": p_obj.ssh_password,
|
||||||
|
"ssh_key": p_obj.ssh_key,
|
||||||
|
"ssh_path": p_obj.ssh_path,
|
||||||
}
|
}
|
||||||
curr = self.get_current_project(sess.chat_id)
|
curr = self.get_current_project(sess.chat_id)
|
||||||
data[str(k)] = {
|
data[str(k)] = {
|
||||||
@@ -1600,6 +1626,22 @@ class AGYEngine:
|
|||||||
except Exception as pe:
|
except Exception as pe:
|
||||||
logger.debug(f"Pre-prompt git pull skipped: {pe}")
|
logger.debug(f"Pre-prompt git pull skipped: {pe}")
|
||||||
|
|
||||||
|
if not auth_manager.has_custom_account(session.chat_id):
|
||||||
|
is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
||||||
|
msg = (
|
||||||
|
"⚠️ <b>حساب کاربری هوش مصنوعی شما متصل نیست!</b>\n\n"
|
||||||
|
"در این ربات حسابهای اشتراکی غیرفعال هستند و هر کاربر ملزم به استفاده از حساب شخصی خود میباشد.\n"
|
||||||
|
"👉 لطفاً با دستور <code>/login</code> وارد حساب اختصاصی گوگل خود شوید یا فایل توکن را ارسال کنید."
|
||||||
|
if is_fa else
|
||||||
|
"⚠️ <b>AGY Account Not Connected!</b>\n\nShared accounts are disabled. Please log in with your own account via /login."
|
||||||
|
)
|
||||||
|
return AGYResult(
|
||||||
|
text=msg,
|
||||||
|
success=False,
|
||||||
|
error="Account not connected"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_env = auth_manager.get_user_env(session.chat_id)
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
@@ -1607,6 +1649,7 @@ class AGYEngine:
|
|||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
limit=100 * 1024 * 1024, # 100 MB buffer limit to handle large stream-json chunks
|
limit=100 * 1024 * 1024, # 100 MB buffer limit to handle large stream-json chunks
|
||||||
cwd=workspace_path,
|
cwd=workspace_path,
|
||||||
|
env=user_env,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1776,7 +1819,7 @@ class AGYEngine:
|
|||||||
text_out = rec["text"]
|
text_out = rec["text"]
|
||||||
elif rec.get("tools"):
|
elif rec.get("tools"):
|
||||||
is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
||||||
tool_bullets = "\n".join(f"• <code>{t}</code>" for t in rec["tools"][-10:])
|
tool_bullets = "\n".join(f"• `{t}`" for t in rec["tools"][-10:])
|
||||||
if is_fa:
|
if is_fa:
|
||||||
text_out = f"✅ **دستورات و ابزارهای زیر با موفقیت اجرا شدند:**\n\n{tool_bullets}"
|
text_out = f"✅ **دستورات و ابزارهای زیر با موفقیت اجرا شدند:**\n\n{tool_bullets}"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import shutil
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, Tuple
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger("AGYAuthManager")
|
||||||
|
|
||||||
|
PROFILES_BASE_DIR = Path("/root/.gemini_profiles")
|
||||||
|
MASTER_TOKEN_PATH = Path("/root/.gemini/antigravity-cli/antigravity-oauth-token")
|
||||||
|
MASTER_SETTINGS_PATH = Path("/root/.gemini/antigravity-cli/settings.json")
|
||||||
|
|
||||||
|
def escape_pre(text: str) -> str:
|
||||||
|
"""Escapes HTML entities for pre block."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
class AuthManager:
|
||||||
|
"""
|
||||||
|
Manages multi-account authentication, isolated user profiles,
|
||||||
|
and OAuth login flows for Antigravity (AGY) CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_dir: Path = PROFILES_BASE_DIR):
|
||||||
|
self.base_dir = base_dir
|
||||||
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Stores active login subprocesses: {user_id: {"proc": Process, "url": str, "started_at": float}}
|
||||||
|
self.active_logins: Dict[int, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def get_profile_dir(self, user_id: int) -> Path:
|
||||||
|
"""Returns the isolated home directory for a given user."""
|
||||||
|
return self.base_dir / str(user_id)
|
||||||
|
|
||||||
|
def get_config_dir(self, user_id: int) -> Path:
|
||||||
|
"""Returns the .gemini/antigravity-cli directory inside user profile."""
|
||||||
|
return self.get_profile_dir(user_id) / ".gemini" / "antigravity-cli"
|
||||||
|
|
||||||
|
def get_token_path(self, user_id: int) -> Path:
|
||||||
|
"""Returns the path to the user's antigravity-oauth-token."""
|
||||||
|
return self.get_config_dir(user_id) / "antigravity-oauth-token"
|
||||||
|
|
||||||
|
def has_custom_account(self, user_id: int) -> bool:
|
||||||
|
"""Checks if a user has a valid personal AGY account token configured."""
|
||||||
|
token_file = self.get_token_path(user_id)
|
||||||
|
if token_file.exists() and token_file.is_file() and token_file.stat().st_size > 10:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_user_home(self, user_id: int) -> str:
|
||||||
|
"""Returns the isolated HOME directory path to use for this user."""
|
||||||
|
return str(self.get_profile_dir(user_id))
|
||||||
|
|
||||||
|
def get_user_env(self, user_id: int) -> Dict[str, str]:
|
||||||
|
"""Returns environment variables dictionary with the proper HOME set for the user."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = self.get_user_home(user_id)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def get_account_status(self, user_id: int) -> Dict[str, Any]:
|
||||||
|
"""Returns detailed status of the user's active account."""
|
||||||
|
has_custom = self.has_custom_account(user_id)
|
||||||
|
token_path = self.get_token_path(user_id)
|
||||||
|
|
||||||
|
modified_time_str = "تنظیم نشده"
|
||||||
|
if has_custom and token_path.exists():
|
||||||
|
try:
|
||||||
|
mtime = token_path.stat().st_mtime
|
||||||
|
modified_time_str = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"has_custom_account": has_custom,
|
||||||
|
"is_default_server": False,
|
||||||
|
"account_type": "حساب اختصاصی (Custom Account)" if has_custom else "حساب متصل نیست (Disconnected)",
|
||||||
|
"token_path": str(token_path) if has_custom else "تنظیم نشده",
|
||||||
|
"last_modified": modified_time_str,
|
||||||
|
"is_login_in_progress": user_id in self.active_logins,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def start_oauth_login(self, user_id: int, timeout: int = 25) -> Tuple[bool, str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Starts headless OAuth login flow for a user via a pseudo-terminal (PTY)
|
||||||
|
so that AGY CLI detects a controlling terminal and outputs the Google Auth URL.
|
||||||
|
"""
|
||||||
|
import pty
|
||||||
|
import select
|
||||||
|
|
||||||
|
# Cancel any previous login session for this user
|
||||||
|
self.cancel_oauth_login(user_id)
|
||||||
|
|
||||||
|
config_dir = self.get_config_dir(user_id)
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
token_file = self.get_token_path(user_id)
|
||||||
|
backup_token = None
|
||||||
|
if token_file.exists():
|
||||||
|
backup_token = token_file.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
try:
|
||||||
|
token_file.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(self.get_profile_dir(user_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"agy", "--print", "/usage",
|
||||||
|
stdin=slave_fd,
|
||||||
|
stdout=slave_fd,
|
||||||
|
stderr=slave_fd,
|
||||||
|
env=env,
|
||||||
|
cwd="/root",
|
||||||
|
close_fds=True,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
os.close(slave_fd)
|
||||||
|
|
||||||
|
auth_url: Optional[str] = None
|
||||||
|
start_time = time.time()
|
||||||
|
url_pattern = re.compile(r"https://accounts\.google\.com/o/oauth2/auth\S+")
|
||||||
|
buffer = ""
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def read_pty_chunk(fd: int) -> str:
|
||||||
|
try:
|
||||||
|
r, _, _ = select.select([fd], [], [], 0.1)
|
||||||
|
if fd in r:
|
||||||
|
return os.read(fd, 2048).decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
chunk = await loop.run_in_executor(None, read_pty_chunk, master_fd)
|
||||||
|
if chunk:
|
||||||
|
buffer += chunk
|
||||||
|
logger.debug(f"OAuth PTY chunk for user {user_id}: {chunk}")
|
||||||
|
m = url_pattern.search(buffer)
|
||||||
|
if m:
|
||||||
|
auth_url = m.group(0).rstrip(").,\r\n\t '\"")
|
||||||
|
break
|
||||||
|
if proc.returncode is not None:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
|
||||||
|
if not auth_url:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if backup_token:
|
||||||
|
token_file.write_text(backup_token, encoding="utf-8")
|
||||||
|
token_file.chmod(0o600)
|
||||||
|
return False, "عدم دریافت لینک احراز هویت از سرور گوگل. لطفاً مجدداً تلاش فرمایید.", None
|
||||||
|
|
||||||
|
self.active_logins[user_id] = {
|
||||||
|
"proc": proc,
|
||||||
|
"master_fd": master_fd,
|
||||||
|
"url": auth_url,
|
||||||
|
"backup_token": backup_token,
|
||||||
|
"started_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return True, "لینک احراز هویت گوگل با موفقیت ایجاد شد.", auth_url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error starting OAuth login for user {user_id}: {e}", exc_info=True)
|
||||||
|
if backup_token:
|
||||||
|
try:
|
||||||
|
token_file.write_text(backup_token, encoding="utf-8")
|
||||||
|
token_file.chmod(0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False, f"خطا در ایجاد فرآیند لاگین: {e}", None
|
||||||
|
|
||||||
|
async def complete_oauth_login(self, user_id: int, auth_code: str, timeout: int = 40) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Completes the OAuth login flow by feeding the authorization code into the waiting AGY PTY process.
|
||||||
|
"""
|
||||||
|
import select
|
||||||
|
|
||||||
|
if user_id not in self.active_logins:
|
||||||
|
return False, "هیچ فرآیند لاگین فعالی برای شما یافت نشد. لطفاً دستور /login را مجدداً ارسال کنید."
|
||||||
|
|
||||||
|
login_data = self.active_logins[user_id]
|
||||||
|
proc = login_data["proc"]
|
||||||
|
master_fd = login_data.get("master_fd")
|
||||||
|
backup_token = login_data.get("backup_token")
|
||||||
|
token_file = self.get_token_path(user_id)
|
||||||
|
|
||||||
|
clean_code = auth_code.strip()
|
||||||
|
clean_code = re.sub(r"^[`'\"]+|[`'\"]+$", "", clean_code)
|
||||||
|
|
||||||
|
try:
|
||||||
|
input_bytes = (clean_code + "\n").encode("utf-8")
|
||||||
|
if master_fd is not None:
|
||||||
|
os.write(master_fd, input_bytes)
|
||||||
|
|
||||||
|
start_t = time.time()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
out_buffer = ""
|
||||||
|
|
||||||
|
def read_all_pty(fd: int) -> str:
|
||||||
|
buf = ""
|
||||||
|
while True:
|
||||||
|
r, _, _ = select.select([fd], [], [], 0.3)
|
||||||
|
if fd in r:
|
||||||
|
try:
|
||||||
|
c = os.read(fd, 2048).decode("utf-8", errors="replace")
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
buf += c
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Poll for token file creation or process termination
|
||||||
|
while time.time() - start_t < timeout:
|
||||||
|
if token_file.exists() and token_file.stat().st_size > 10:
|
||||||
|
token_file.chmod(0o600)
|
||||||
|
self.cancel_oauth_login(user_id)
|
||||||
|
return True, "🎉 تبریک! ورود با حساب گوگل اختصاصی شما با موفقیت انجام و ذخیره شد."
|
||||||
|
|
||||||
|
if proc.returncode is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# Read any output from pty
|
||||||
|
if master_fd is not None:
|
||||||
|
out_buffer = await loop.run_in_executor(None, read_all_pty, master_fd)
|
||||||
|
|
||||||
|
self.cancel_oauth_login(user_id)
|
||||||
|
|
||||||
|
if token_file.exists() and token_file.stat().st_size > 10:
|
||||||
|
token_file.chmod(0o600)
|
||||||
|
return True, "🎉 تبریک! ورود با حساب گوگل اختصاصی شما با موفقیت انجام و ذخیره شد."
|
||||||
|
|
||||||
|
if backup_token:
|
||||||
|
token_file.write_text(backup_token, encoding="utf-8")
|
||||||
|
token_file.chmod(0o600)
|
||||||
|
|
||||||
|
err_msg = out_buffer.strip()
|
||||||
|
return False, f"کد تایید معتبر نبود یا فرآیند با خطا مواجه شد:\n<pre>{escape_pre(err_msg or 'کد ارسالی نامعتبر بود یا منقضی شده است.')}</pre>"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error completing OAuth login for user {user_id}: {e}", exc_info=True)
|
||||||
|
self.cancel_oauth_login(user_id)
|
||||||
|
return False, f"خطا در ثبت کد احراز هویت: {e}"
|
||||||
|
|
||||||
|
def cancel_oauth_login(self, user_id: int) -> bool:
|
||||||
|
"""Cancels any pending login flow for a user and cleans up PTY."""
|
||||||
|
login_data = self.active_logins.pop(user_id, None)
|
||||||
|
if login_data:
|
||||||
|
proc = login_data.get("proc")
|
||||||
|
master_fd = login_data.get("master_fd")
|
||||||
|
if proc:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if master_fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
backup_token = login_data.get("backup_token")
|
||||||
|
token_file = self.get_token_path(user_id)
|
||||||
|
if backup_token and not token_file.exists():
|
||||||
|
try:
|
||||||
|
token_file.write_text(backup_token, encoding="utf-8")
|
||||||
|
token_file.chmod(0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def import_token(self, user_id: int, token_data_str: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Directly imports an existing antigravity-oauth-token content for the user.
|
||||||
|
"""
|
||||||
|
raw_text = token_data_str.strip()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_text)
|
||||||
|
if not isinstance(parsed, dict) or ("token" not in parsed and "access_token" not in parsed and "refresh_token" not in parsed):
|
||||||
|
return False, "فرمت فایل یا متن توکن معتبر نیست. توکن باید فایل JSON حاوی اطلاعات احراز هویت AGY باشد."
|
||||||
|
except Exception:
|
||||||
|
return False, "متن ارسالی یک ساختار JSON معتبر برای توکن AGY نیست."
|
||||||
|
|
||||||
|
config_dir = self.get_config_dir(user_id)
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
token_file = self.get_token_path(user_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_file.write_text(raw_text, encoding="utf-8")
|
||||||
|
token_file.chmod(0o600)
|
||||||
|
return True, "✅ فایل توکن حساب اختصاصی شما با موفقیت ثبت و فعال شد."
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"خطا در ذخیرهسازی توکن: {e}"
|
||||||
|
|
||||||
|
def logout_user(self, user_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Logs out a user by removing their custom token.
|
||||||
|
"""
|
||||||
|
self.cancel_oauth_login(user_id)
|
||||||
|
token_file = self.get_token_path(user_id)
|
||||||
|
if token_file.exists():
|
||||||
|
try:
|
||||||
|
token_file.unlink()
|
||||||
|
return True, "حساب اختصاصی شما با موفقیت خارج شد. برای ارسال دستورات، ورود مجدد با حساب شخصی الزامی است."
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"خطا در حذف توکن اختصاصی: {e}"
|
||||||
|
return True, "شما در حال حاضر حساب متصلی ندارید."
|
||||||
|
|
||||||
|
def copy_master_token_to_user(self, user_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Disabled: Shared accounts are completely removed.
|
||||||
|
"""
|
||||||
|
return False, "حسابهای اشتراکی در این ربات غیرفعال شدهاند و هر کاربر باید با حساب اختصاصی خود وارد شود."
|
||||||
|
|
||||||
|
auth_manager = AuthManager()
|
||||||
+1131
-27
File diff suppressed because it is too large
Load Diff
+327
-12
@@ -1623,7 +1623,301 @@ async def execute_action(
|
|||||||
return badge, side_effects, created_task
|
return badge, side_effects, created_task
|
||||||
|
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
# 35. SAVE_MEMORY / STORE_MEMORY / SET_MEMORY
|
# 35. SSH_CONFIG / SETUP_SSH / SET_SSH
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
elif act in ("SSH_CONFIG", "SETUP_SSH", "SET_SSH", "CONFIGURE_SSH"):
|
||||||
|
p_name = attrs.get("project") or attrs.get("name") or current_project_name
|
||||||
|
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
|
||||||
|
if not proj:
|
||||||
|
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
|
||||||
|
|
||||||
|
host = attrs.get("host") or attrs.get("server") or attrs.get("ssh_host")
|
||||||
|
port_raw = attrs.get("port") or attrs.get("ssh_port") or "22"
|
||||||
|
user = attrs.get("user") or attrs.get("username") or attrs.get("ssh_user") or ""
|
||||||
|
path = attrs.get("path") or attrs.get("dir") or attrs.get("remote_path") or attrs.get("ssh_path") or "/"
|
||||||
|
|
||||||
|
try:
|
||||||
|
port = int(port_raw)
|
||||||
|
except Exception:
|
||||||
|
port = 22
|
||||||
|
|
||||||
|
if host:
|
||||||
|
proj.ssh_host = host.strip()
|
||||||
|
proj.ssh_port = port
|
||||||
|
proj.ssh_user = user.strip() if user else proj.ssh_user
|
||||||
|
proj.ssh_path = path.strip()
|
||||||
|
session_manager.save()
|
||||||
|
|
||||||
|
from remote_audit import remote_audit
|
||||||
|
remote_audit.log_operation(
|
||||||
|
user_id=chat_id,
|
||||||
|
project_name=proj.name,
|
||||||
|
service_type="ssh",
|
||||||
|
action_type="update_config",
|
||||||
|
requester="ai_agent",
|
||||||
|
status="success",
|
||||||
|
details=f"Host: {proj.ssh_host}:{proj.ssh_port}, Path: {proj.ssh_path}",
|
||||||
|
result_summary="SSH configuration updated",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
from ssh_manager import ssh_manager
|
||||||
|
test_ok, test_msg = await ssh_manager.test_connection(
|
||||||
|
host=proj.ssh_host,
|
||||||
|
port=proj.ssh_port,
|
||||||
|
user=proj.ssh_user or "",
|
||||||
|
password=proj.ssh_password,
|
||||||
|
key=proj.ssh_key,
|
||||||
|
path=proj.ssh_path,
|
||||||
|
user_id=chat_id,
|
||||||
|
project_name=proj.name,
|
||||||
|
requester="ai_agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
status_icon = "✅" if test_ok else "⚠️"
|
||||||
|
badge = (
|
||||||
|
f"\n\n💻 <b>تنظیمات اتصال SSH برای پروژه <code>{escape_html(proj.name)}</code> بهروز شد:</b>\n"
|
||||||
|
f"• 🌐 <b>هاست:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
|
||||||
|
f"• 👤 <b>نام کاربری:</b> <code>{escape_html(proj.ssh_user or '(پیشفرض root)')}</code>\n"
|
||||||
|
f"• 📂 <b>مسیر ریموت:</b> <code>{escape_html(proj.ssh_path)}</code>\n"
|
||||||
|
f"• {status_icon} <b>نتیجه تست اتصال:</b> {test_msg}"
|
||||||
|
if is_fa else
|
||||||
|
f"\n\n💻 <b>SSH Configuration updated for project <code>{escape_html(proj.name)}</code>:</b>\n"
|
||||||
|
f"• 🌐 <b>Host:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
|
||||||
|
f"• 👤 <b>User:</b> <code>{escape_html(proj.ssh_user or '(default root)')}</code>\n"
|
||||||
|
f"• 📂 <b>Remote Path:</b> <code>{escape_html(proj.ssh_path)}</code>\n"
|
||||||
|
f"• {status_icon} <b>Connection test:</b> {test_msg}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
curr_host = proj.ssh_host or "(تنظیم نشده)"
|
||||||
|
badge = (
|
||||||
|
f"\n\nℹ️ <b>وضعیت تنظیمات SSH پروژه <code>{escape_html(proj.name)}</code>:</b>\n"
|
||||||
|
f"• 🌐 <b>هاست:</b> <code>{escape_html(curr_host)}:{proj.ssh_port}</code>\n"
|
||||||
|
f"• 👤 <b>کاربر:</b> <code>{escape_html(proj.ssh_user or '(تعیین نشده)')}</code>\n"
|
||||||
|
f"• 📂 <b>مسیر:</b> <code>{escape_html(proj.ssh_path or '/')}</code>"
|
||||||
|
if is_fa else
|
||||||
|
f"\n\nℹ️ <b>SSH Status for <code>{escape_html(proj.name)}</code>:</b>\n"
|
||||||
|
f"• 🌐 <b>Host:</b> <code>{escape_html(curr_host)}:{proj.ssh_port}</code>\n"
|
||||||
|
f"• 👤 <b>User:</b> <code>{escape_html(proj.ssh_user or '(none)')}</code>\n"
|
||||||
|
f"• 📂 <b>Path:</b> <code>{escape_html(proj.ssh_path or '/')}</code>"
|
||||||
|
)
|
||||||
|
return badge, side_effects, created_task
|
||||||
|
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# 36. SSH_TEST / TEST_SSH
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
elif act in ("SSH_TEST", "TEST_SSH", "CHECK_SSH"):
|
||||||
|
p_name = attrs.get("project") or attrs.get("name") or current_project_name
|
||||||
|
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
|
||||||
|
if not proj:
|
||||||
|
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
|
||||||
|
|
||||||
|
if not proj.ssh_host:
|
||||||
|
return "\n⚠️ اطلاعات سرور SSH برای این پروژه ثبت نشده است. میتوانید از منوی <code>/ssh</code> آن را تنظیم کنید." if is_fa else "\n⚠️ SSH credentials are not set for this project.", side_effects, created_task
|
||||||
|
|
||||||
|
from ssh_manager import ssh_manager
|
||||||
|
test_ok, test_msg = await ssh_manager.test_connection(
|
||||||
|
host=proj.ssh_host,
|
||||||
|
port=proj.ssh_port,
|
||||||
|
user=proj.ssh_user or "",
|
||||||
|
password=proj.ssh_password,
|
||||||
|
key=proj.ssh_key,
|
||||||
|
path=proj.ssh_path or "/",
|
||||||
|
user_id=chat_id,
|
||||||
|
project_name=proj.name,
|
||||||
|
requester="ai_agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
icon = "✅" if test_ok else "❌"
|
||||||
|
badge = (
|
||||||
|
f"\n\n{icon} <b>تست اتصال سرور SSH پروژه <code>{escape_html(proj.name)}</code>:</b>\n"
|
||||||
|
f"• 🌐 <b>سرور:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
|
||||||
|
f"• 📂 <b>مسیر:</b> <code>{escape_html(proj.ssh_path or '/')}</code>\n"
|
||||||
|
f"• 📋 <b>وضعیت:</b>\n{test_msg}"
|
||||||
|
if is_fa else
|
||||||
|
f"\n\n{icon} <b>SSH Test Result for <code>{escape_html(proj.name)}</code>:</b>\n"
|
||||||
|
f"• 🌐 <b>Server:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
|
||||||
|
f"• 📂 <b>Path:</b> <code>{escape_html(proj.ssh_path or '/')}</code>\n"
|
||||||
|
f"• 📋 <b>Status:</b>\n{test_msg}"
|
||||||
|
)
|
||||||
|
return badge, side_effects, created_task
|
||||||
|
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# 37. SSH_PUSH / PUSH_SSH / SSH_DEPLOY
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
elif act in ("SSH_PUSH", "PUSH_SSH", "SSH_DEPLOY", "DEPLOY_SSH"):
|
||||||
|
p_name = attrs.get("project") or attrs.get("name") or current_project_name
|
||||||
|
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
|
||||||
|
if not proj:
|
||||||
|
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
|
||||||
|
|
||||||
|
if not proj.ssh_host:
|
||||||
|
return "\n⚠️ مشخصات سرور SSH برای این پروژه یافت نشد. لطفاً ابتدا در منوی <code>/ssh</code> مشخصات را تنظیم نمایید." if is_fa else "\n⚠️ SSH configuration not found for this project.", side_effects, created_task
|
||||||
|
|
||||||
|
from ssh_manager import ssh_manager
|
||||||
|
from git_manager import git_manager
|
||||||
|
try:
|
||||||
|
await git_manager.git_publish(proj.workspace, message="Auto-publish before SSH push", repo_name=proj.name)
|
||||||
|
except Exception as pe:
|
||||||
|
logger.warning(f"Auto-publish warning before SSH push: {pe}")
|
||||||
|
|
||||||
|
res = await ssh_manager.push_project(
|
||||||
|
workspace_path=proj.workspace,
|
||||||
|
host=proj.ssh_host,
|
||||||
|
port=proj.ssh_port,
|
||||||
|
user=proj.ssh_user or "",
|
||||||
|
password=proj.ssh_password,
|
||||||
|
key=proj.ssh_key,
|
||||||
|
remote_path=proj.ssh_path or "/",
|
||||||
|
branch="production",
|
||||||
|
user_id=chat_id,
|
||||||
|
project_name=proj.name,
|
||||||
|
requester="ai_agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
if res.get("success"):
|
||||||
|
kb = round(res.get("bytes_transferred", 0) / 1024, 2)
|
||||||
|
mb = round(kb / 1024, 2)
|
||||||
|
size_str = f"{mb} مگابایت" if mb >= 1.0 else f"{kb} کیلوبایت"
|
||||||
|
files_up = res.get("files_uploaded", 0)
|
||||||
|
files_skip = res.get("files_skipped", 0)
|
||||||
|
duration = round(res.get("duration", 0), 2)
|
||||||
|
target_p = proj.ssh_path or "/"
|
||||||
|
|
||||||
|
badge = (
|
||||||
|
f"\n\n🚀 <b>کدهای شاخه production با موفقیت به سرور SSH منتقل شدند:</b>\n"
|
||||||
|
f"• 📁 <b>پروژه:</b> <code>{escape_html(proj.name)}</code>\n"
|
||||||
|
f"• 🌐 <b>مقصد ریموت:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code> (مسیر: <code>{escape_html(target_p)}</code>)\n"
|
||||||
|
f"• 📤 <b>فایلهای آپلودشده:</b> <b>{files_up}</b> فایل ({size_str})\n"
|
||||||
|
f"• 🛡️ <b>دادههای محلی/دیتابیس/آپلود نادیده گرفتهشده:</b> <b>{files_skip}</b> آیتم\n"
|
||||||
|
f"• 🔒 <b>دادههای سرور پروداکشن:</b> کاملاً دستنخورده و ایمن حفظ شدند\n"
|
||||||
|
f"• ⏱️ <b>مدت زمان انتقال:</b> <code>{duration}</code> ثانیه"
|
||||||
|
if is_fa else
|
||||||
|
f"\n\n🚀 <b>Production branch pushed successfully to SSH server:</b>\n"
|
||||||
|
f"• 📁 <b>Project:</b> <code>{escape_html(proj.name)}</code>\n"
|
||||||
|
f"• 🌐 <b>Remote Target:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code> (Path: <code>{escape_html(target_p)}</code>)\n"
|
||||||
|
f"• 📤 <b>Files Uploaded:</b> <b>{files_up}</b> ({size_str})\n"
|
||||||
|
f"• 🛡️ <b>Skipped Junk/Databases:</b> <b>{files_skip}</b> items\n"
|
||||||
|
f"• 🔒 <b>Remote production data:</b> Intact & Protected\n"
|
||||||
|
f"• ⏱️ <b>Duration:</b> <code>{duration}s</code>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
err = res.get("error", "Unknown error")
|
||||||
|
badge = f"\n⚠️ <b>خطا در پوش SSH به سرور مقصد:</b> {escape_html(str(err))}" if is_fa else f"\n⚠️ <b>SSH Push Failed:</b> {escape_html(str(err))}"
|
||||||
|
return badge, side_effects, created_task
|
||||||
|
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# 38. SSH_PULL / PULL_SSH / SSH_FETCH
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
elif act in ("SSH_PULL", "PULL_SSH", "SSH_FETCH", "FETCH_SSH"):
|
||||||
|
p_name = attrs.get("project") or attrs.get("name") or current_project_name
|
||||||
|
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
|
||||||
|
if not proj:
|
||||||
|
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
|
||||||
|
|
||||||
|
if not proj.ssh_host:
|
||||||
|
return "\n⚠️ مشخصات سرور SSH برای این پروژه یافت نشد." if is_fa else "\n⚠️ SSH configuration not found for this project.", side_effects, created_task
|
||||||
|
|
||||||
|
from ssh_manager import ssh_manager
|
||||||
|
res = await ssh_manager.pull_project(
|
||||||
|
workspace_path=proj.workspace,
|
||||||
|
host=proj.ssh_host,
|
||||||
|
port=proj.ssh_port,
|
||||||
|
user=proj.ssh_user or "",
|
||||||
|
password=proj.ssh_password,
|
||||||
|
key=proj.ssh_key,
|
||||||
|
remote_path=proj.ssh_path or "/",
|
||||||
|
branch="production",
|
||||||
|
user_id=chat_id,
|
||||||
|
project_name=proj.name,
|
||||||
|
requester="ai_agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
if res.get("success"):
|
||||||
|
kb = round(res.get("bytes_transferred", 0) / 1024, 2)
|
||||||
|
mb = round(kb / 1024, 2)
|
||||||
|
size_str = f"{mb} مگابایت" if mb >= 1.0 else f"{kb} کیلوبایت"
|
||||||
|
files_down = res.get("files_downloaded", 0)
|
||||||
|
files_skip = res.get("files_skipped", 0)
|
||||||
|
duration = round(res.get("duration", 0), 2)
|
||||||
|
|
||||||
|
badge = (
|
||||||
|
f"\n\n📥 <b>فایلهای پروژه از سرور SSH با موفقیت دریافت شدند (Pull):</b>\n"
|
||||||
|
f"• 📁 <b>پروژه:</b> <code>{escape_html(proj.name)}</code>\n"
|
||||||
|
f"• 📥 <b>فایلهای دریافتشده:</b> <b>{files_down}</b> فایل ({size_str})\n"
|
||||||
|
f"• 🛡️ <b>فایلهای نادیده گرفتهشده (دیتابیس/لاگ/آپلود):</b> <b>{files_skip}</b> آیتم\n"
|
||||||
|
f"• ⏱️ <b>مدت زمان انتقال:</b> <code>{duration}</code> ثانیه"
|
||||||
|
if is_fa else
|
||||||
|
f"\n\n📥 <b>Project pulled successfully from SSH server:</b>\n"
|
||||||
|
f"• 📁 <b>Project:</b> <code>{escape_html(proj.name)}</code>\n"
|
||||||
|
f"• 📥 <b>Files Downloaded:</b> <b>{files_down}</b> ({size_str})\n"
|
||||||
|
f"• 🛡️ <b>Skipped (Databases/Logs/Uploads):</b> <b>{files_skip}</b> items\n"
|
||||||
|
f"• ⏱️ <b>Duration:</b> <code>{duration}s</code>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
err = res.get("error", "Unknown error")
|
||||||
|
badge = f"\n⚠️ <b>خطا در دریافت فایلها از سرور SSH:</b> {escape_html(str(err))}" if is_fa else f"\n⚠️ <b>SSH Pull Failed:</b> {escape_html(str(err))}"
|
||||||
|
return badge, side_effects, created_task
|
||||||
|
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# 39. SSH_EXEC / EXEC_SSH / RUN_SSH
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
elif act in ("SSH_EXEC", "EXEC_SSH", "RUN_SSH", "SSH_COMMAND"):
|
||||||
|
p_name = attrs.get("project") or attrs.get("name") or current_project_name
|
||||||
|
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
|
||||||
|
if not proj:
|
||||||
|
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
|
||||||
|
|
||||||
|
if not proj.ssh_host:
|
||||||
|
return "\n⚠️ مشخصات سرور SSH برای این پروژه یافت نشد." if is_fa else "\n⚠️ SSH configuration not found for this project.", side_effects, created_task
|
||||||
|
|
||||||
|
cmd = attrs.get("command") or attrs.get("cmd") or attrs.get("_default") or ""
|
||||||
|
if not cmd.strip():
|
||||||
|
return "\n⚠️ دستور اجرایی برای سرور SSH تعیین نشده است." if is_fa else "\n⚠️ SSH command is required.", side_effects, created_task
|
||||||
|
|
||||||
|
from ssh_manager import ssh_manager
|
||||||
|
ok, out, code = await ssh_manager.execute_command(
|
||||||
|
host=proj.ssh_host,
|
||||||
|
port=proj.ssh_port,
|
||||||
|
user=proj.ssh_user or "",
|
||||||
|
password=proj.ssh_password,
|
||||||
|
key=proj.ssh_key,
|
||||||
|
remote_path=proj.ssh_path or "/",
|
||||||
|
command=cmd,
|
||||||
|
user_id=chat_id,
|
||||||
|
project_name=proj.name,
|
||||||
|
requester="ai_agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
st_icon = "✅" if ok else "❌"
|
||||||
|
badge = (
|
||||||
|
f"\n\n{st_icon} <b>اجرای دستور روی سرور SSH پروژه <code>{escape_html(proj.name)}</code>:</b>\n"
|
||||||
|
f"• 💻 <b>دستور:</b> <code>{escape_html(cmd)}</code>\n"
|
||||||
|
f"• 🔢 <b>کد خروج:</b> <code>{code}</code>\n"
|
||||||
|
f"• 📜 <b>خروجی سرور:</b>\n<pre>{escape_html(out)}</pre>"
|
||||||
|
if is_fa else
|
||||||
|
f"\n\n{st_icon} <b>SSH Command Execution on <code>{escape_html(proj.name)}</code>:</b>\n"
|
||||||
|
f"• 💻 <b>Command:</b> <code>{escape_html(cmd)}</code>\n"
|
||||||
|
f"• 🔢 <b>Exit Code:</b> <code>{code}</code>\n"
|
||||||
|
f"• 📜 <b>Output:</b>\n<pre>{escape_html(out)}</pre>"
|
||||||
|
)
|
||||||
|
return badge, side_effects, created_task
|
||||||
|
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# 40. SSH_LOGS / REMOTE_LOGS / AUDIT_LOGS
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
elif act in ("SSH_LOGS", "REMOTE_LOGS", "AUDIT_LOGS", "SSH_AUDIT"):
|
||||||
|
p_name = attrs.get("project") or attrs.get("name") or current_project_name
|
||||||
|
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
|
||||||
|
proj_name = proj.name if proj else (p_name or "default")
|
||||||
|
|
||||||
|
from remote_audit import remote_audit
|
||||||
|
logs = remote_audit.get_recent_logs(user_id=chat_id, project_name=proj_name, limit=10)
|
||||||
|
badge = "\n\n" + remote_audit.format_logs_for_tg(logs, project_name=proj_name, is_fa=is_fa)
|
||||||
|
return badge, side_effects, created_task
|
||||||
|
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# 41. SAVE_MEMORY / STORE_MEMORY / SET_MEMORY
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
elif act in ("SAVE_MEMORY", "STORE_MEMORY", "SET_MEMORY", "ADD_MEMORY"):
|
elif act in ("SAVE_MEMORY", "STORE_MEMORY", "SET_MEMORY", "ADD_MEMORY"):
|
||||||
from memory_manager import memory_manager
|
from memory_manager import memory_manager
|
||||||
@@ -1649,13 +1943,17 @@ async def execute_action(
|
|||||||
created_task,
|
created_task,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Map types: 'project' -> project tier, 'user'/'private' -> user tier, 'global' -> rejected/downgraded for AI
|
# Map types: 'project' -> project tier, 'user'/'private' -> user tier, 'global' -> global tier (if admin)
|
||||||
|
is_admin_user = settings.is_admin(chat_id)
|
||||||
if raw_type in ("project", "proj"):
|
if raw_type in ("project", "proj"):
|
||||||
mem_type = "project"
|
mem_type = "project"
|
||||||
proj_name = req_project
|
proj_name = req_project
|
||||||
elif raw_type == "global":
|
elif raw_type == "global":
|
||||||
# Global memories are strictly bot-managed by Admin. AI cannot auto-save global memories.
|
if is_admin_user:
|
||||||
logger.info("AI attempted global memory save for user %s; safely converting to user memory.", chat_id)
|
mem_type = "global"
|
||||||
|
proj_name = None
|
||||||
|
else:
|
||||||
|
logger.info("Non-admin user %s attempted global memory save; converting to user memory.", chat_id)
|
||||||
mem_type = "user"
|
mem_type = "user"
|
||||||
proj_name = None
|
proj_name = None
|
||||||
else:
|
else:
|
||||||
@@ -1667,7 +1965,7 @@ async def execute_action(
|
|||||||
type_=mem_type,
|
type_=mem_type,
|
||||||
key=key,
|
key=key,
|
||||||
content=content,
|
content=content,
|
||||||
user_id=chat_id,
|
user_id=None if mem_type == "global" else chat_id,
|
||||||
project_name=proj_name,
|
project_name=proj_name,
|
||||||
category=category,
|
category=category,
|
||||||
importance=importance,
|
importance=importance,
|
||||||
@@ -1720,28 +2018,37 @@ async def execute_action(
|
|||||||
created_task,
|
created_task,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_admin_user = settings.is_admin(chat_id)
|
||||||
if raw_type in ("project", "proj"):
|
if raw_type in ("project", "proj"):
|
||||||
mem_type = "project"
|
mem_type = "project"
|
||||||
proj_name = req_project
|
proj_name = req_project
|
||||||
|
elif raw_type == "global" and is_admin_user:
|
||||||
|
mem_type = "global"
|
||||||
|
proj_name = None
|
||||||
else:
|
else:
|
||||||
mem_type = "user"
|
mem_type = "user"
|
||||||
proj_name = None
|
proj_name = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
existing = memory_manager.get_by_key(type_=mem_type, key=key, user_id=chat_id, project_name=proj_name)
|
existing = memory_manager.get_by_key(type_=mem_type, key=key, user_id=None if mem_type == "global" else chat_id, project_name=proj_name)
|
||||||
cat = category or (existing.category if existing else "general")
|
cat = category or (existing.category if existing else "general")
|
||||||
imp = int(importance_raw) if importance_raw and importance_raw.isdigit() else (existing.importance if existing else 1)
|
imp = int(importance_raw) if importance_raw and importance_raw.isdigit() else (existing.importance if existing else 1)
|
||||||
item, is_created = memory_manager.save_or_update(
|
item, is_created = memory_manager.save_or_update(
|
||||||
type_=mem_type,
|
type_=mem_type,
|
||||||
key=key,
|
key=key,
|
||||||
content=content,
|
content=content,
|
||||||
user_id=chat_id,
|
user_id=None if mem_type == "global" else chat_id,
|
||||||
project_name=proj_name,
|
project_name=proj_name,
|
||||||
category=cat,
|
category=cat,
|
||||||
importance=imp,
|
importance=imp,
|
||||||
created_by=chat_id,
|
created_by=chat_id,
|
||||||
)
|
)
|
||||||
type_label = f"📁 پروژه (<code>{escape_html(proj_name or '')}</code>)" if item.is_project else "👤 کاربر (سراسری)"
|
if item.is_project:
|
||||||
|
type_label = f"📁 پروژه (<code>{escape_html(proj_name or '')}</code>)"
|
||||||
|
elif item.is_user:
|
||||||
|
type_label = "👤 کاربر (سراسری)"
|
||||||
|
else:
|
||||||
|
type_label = "🌐 عمومی (سیستم)"
|
||||||
badge = (
|
badge = (
|
||||||
f"\n\n🧠 <b>خاطره هوش مصنوعی بهروزرسانی شد:</b>\n"
|
f"\n\n🧠 <b>خاطره هوش مصنوعی بهروزرسانی شد:</b>\n"
|
||||||
f"• 🏷️ <b>بخش:</b> {type_label}\n"
|
f"• 🏷️ <b>بخش:</b> {type_label}\n"
|
||||||
@@ -1768,16 +2075,24 @@ async def execute_action(
|
|||||||
key = attrs.get("key") or attrs.get("slug") or attrs.get("_default")
|
key = attrs.get("key") or attrs.get("slug") or attrs.get("_default")
|
||||||
raw_type = (attrs.get("type") or "user").lower().strip()
|
raw_type = (attrs.get("type") or "user").lower().strip()
|
||||||
req_project = attrs.get("project") or attrs.get("proj") or current_project_name
|
req_project = attrs.get("project") or attrs.get("proj") or current_project_name
|
||||||
|
is_admin_user = settings.is_admin(chat_id)
|
||||||
|
|
||||||
mem_type = "project" if raw_type in ("project", "proj") else "user"
|
if raw_type in ("project", "proj"):
|
||||||
proj_name = req_project if mem_type == "project" else None
|
mem_type = "project"
|
||||||
|
proj_name = req_project
|
||||||
|
elif raw_type == "global" and is_admin_user:
|
||||||
|
mem_type = "global"
|
||||||
|
proj_name = None
|
||||||
|
else:
|
||||||
|
mem_type = "user"
|
||||||
|
proj_name = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if mem_id_raw and mem_id_raw.isdigit():
|
if mem_id_raw and mem_id_raw.isdigit():
|
||||||
ok = memory_manager.delete_by_id(int(mem_id_raw), user_id=chat_id, is_admin=is_admin)
|
ok = memory_manager.delete_by_id(int(mem_id_raw), user_id=chat_id, is_admin=is_admin_user)
|
||||||
elif key:
|
elif key:
|
||||||
ok = memory_manager.delete_by_key(
|
ok = memory_manager.delete_by_key(
|
||||||
type_=mem_type, key=key, user_id=chat_id, project_name=proj_name, is_admin=is_admin
|
type_=mem_type, key=key, user_id=None if mem_type == "global" else chat_id, project_name=proj_name, is_admin=is_admin_user
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return "\n⚠️ لطفاً شناسه یا کلید خاطره جهت حذف را مشخص کنید." if is_fa else "\n⚠️ Please specify memory id or key to delete.", side_effects, created_task
|
return "\n⚠️ لطفاً شناسه یا کلید خاطره جهت حذف را مشخص کنید." if is_fa else "\n⚠️ Please specify memory id or key to delete.", side_effects, created_task
|
||||||
|
|||||||
Binary file not shown.
@@ -622,6 +622,7 @@ def format_ftp_info(
|
|||||||
user: Optional[str] = None,
|
user: Optional[str] = None,
|
||||||
path: str = "/",
|
path: str = "/",
|
||||||
tls: bool = False,
|
tls: bool = False,
|
||||||
|
password: Optional[str] = None,
|
||||||
lang: str = "fa",
|
lang: str = "fa",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Formats FTP connection and deployment status for Telegram HTML."""
|
"""Formats FTP connection and deployment status for Telegram HTML."""
|
||||||
@@ -630,18 +631,20 @@ def format_ftp_info(
|
|||||||
host_str = f"<code>{escape_html(host)}:{port}</code>" if host else ("<i>(تنظیم نشده)</i>" if is_fa else "<i>(Not configured)</i>")
|
host_str = f"<code>{escape_html(host)}:{port}</code>" if host else ("<i>(تنظیم نشده)</i>" if is_fa else "<i>(Not configured)</i>")
|
||||||
user_str = f"<code>{escape_html(user)}</code>" if user else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>")
|
user_str = f"<code>{escape_html(user)}</code>" if user else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>")
|
||||||
path_str = f"<code>{escape_html(path)}</code>"
|
path_str = f"<code>{escape_html(path)}</code>"
|
||||||
|
pass_str = f"<code>{'•' * min(len(password), 8)}</code> <i>(تنظیم شده)</i>" if password else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>")
|
||||||
tls_str = "بله (FTPS/TLS امن) 🔒" if tls else "خیر (FTP معمولی)"
|
tls_str = "بله (FTPS/TLS امن) 🔒" if tls else "خیر (FTP معمولی)"
|
||||||
tls_str_en = "Yes (Secure FTPS/TLS) 🔒" if tls else "No (Standard FTP)"
|
tls_str_en = "Yes (Secure FTPS/TLS) 🔒" if tls else "No (Standard FTP)"
|
||||||
|
|
||||||
if is_fa:
|
if is_fa:
|
||||||
return (
|
return (
|
||||||
f"🚀 <b>تنظیمات استقرار و دیپلوی FTP پروژه</b>\n\n"
|
f"🚀 <b>تنظیمات استقرار و مشخصات FTP پروژه</b>\n\n"
|
||||||
f"• 📁 <b>پروژه:</b> <code>{clean_p}</code>\n"
|
f"• 📁 <b>پروژه:</b> <code>{clean_p}</code>\n"
|
||||||
f"• 🌐 <b>سرور / هاست FTP:</b> {host_str}\n"
|
f"• 🌐 <b>سرور / هاست:</b> {host_str}\n"
|
||||||
f"• 👤 <b>نام کاربری:</b> {user_str}\n"
|
f"• 👤 <b>نام کاربری:</b> {user_str}\n"
|
||||||
|
f"• 🔑 <b>رمز عبور:</b> {pass_str}\n"
|
||||||
f"• 📂 <b>مسیر مقصد روی هاست:</b> {path_str}\n"
|
f"• 📂 <b>مسیر مقصد روی هاست:</b> {path_str}\n"
|
||||||
f"• 🔒 <b>پروتکل امن (FTPS):</b> {tls_str}\n\n"
|
f"• 🔒 <b>پروتکل امن (FTPS):</b> {tls_str}\n\n"
|
||||||
f"💡 <i>در زمان دیپلوی، هوش مصنوعی فایلهای زائد، کش، سشنها و مخزن گیت (.git) را به صورت خودکار فیلتر کرده و تنها سورس اصلی و تمیز به سرور منتقل میشود.</i>"
|
f"💡 <i>میتوانید با دکمههای زیر هر فیلد را به دلخواه ویرایش، تست یا ذخیره کنید. در زمان دیپلوی، فایلهای زائد، کش و .git به صورت خودکار فیلتر میشوند.</i>"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return (
|
return (
|
||||||
@@ -649,10 +652,69 @@ def format_ftp_info(
|
|||||||
f"• 📁 <b>Project:</b> <code>{clean_p}</code>\n"
|
f"• 📁 <b>Project:</b> <code>{clean_p}</code>\n"
|
||||||
f"• 🌐 <b>FTP Host:</b> {host_str}\n"
|
f"• 🌐 <b>FTP Host:</b> {host_str}\n"
|
||||||
f"• 👤 <b>Username:</b> {user_str}\n"
|
f"• 👤 <b>Username:</b> {user_str}\n"
|
||||||
f"• 📂 <b>Remote Target Path:</b> {path_str}\n"
|
f"• 🔑 <b>Password:</b> {pass_str}\n"
|
||||||
|
f"• 📂 <b>Remote Path:</b> {path_str}\n"
|
||||||
f"• 🔒 <b>Secure FTPS/TLS:</b> {tls_str_en}\n\n"
|
f"• 🔒 <b>Secure FTPS/TLS:</b> {tls_str_en}\n\n"
|
||||||
f"💡 <i>During deployment, unnecessary cache, temp, sessions, and .git files are automatically excluded for a clean production upload.</i>"
|
f"💡 <i>Use the buttons below to edit fields, test connection, or deploy. Junk and cache files are excluded automatically.</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_ssh_info(
|
||||||
|
project_name: str,
|
||||||
|
host: Optional[str],
|
||||||
|
port: int = 22,
|
||||||
|
user: Optional[str] = None,
|
||||||
|
path: str = "/",
|
||||||
|
password: Optional[str] = None,
|
||||||
|
has_key: bool = False,
|
||||||
|
lang: str = "fa",
|
||||||
|
) -> str:
|
||||||
|
"""Formats SSH connection and remote development status for Telegram HTML."""
|
||||||
|
is_fa = (lang or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
||||||
|
clean_p = escape_html(project_name)
|
||||||
|
host_str = f"<code>{escape_html(host)}:{port}</code>" if host else ("<i>(تنظیم نشده)</i>" if is_fa else "<i>(Not configured)</i>")
|
||||||
|
user_str = f"<code>{escape_html(user)}</code>" if user else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>")
|
||||||
|
path_str = f"<code>{escape_html(path)}</code>"
|
||||||
|
|
||||||
|
auth_methods = []
|
||||||
|
if password:
|
||||||
|
auth_methods.append("رمز عبور 🔑" if is_fa else "Password 🔑")
|
||||||
|
if has_key:
|
||||||
|
auth_methods.append("کلید اختصاصی SSH 🗝️" if is_fa else "SSH Private Key 🗝️")
|
||||||
|
if not auth_methods:
|
||||||
|
auth_str = "<i>(تعیین نشده)</i>" if is_fa else "<i>(Not set)</i>"
|
||||||
|
else:
|
||||||
|
auth_str = " + ".join(auth_methods)
|
||||||
|
|
||||||
|
if is_fa:
|
||||||
|
return (
|
||||||
|
f"💻 <b>تنظیمات ریموت دولوپ و سرور SSH پروژه</b>\n\n"
|
||||||
|
f"• 📁 <b>پروژه:</b> <code>{clean_p}</code>\n"
|
||||||
|
f"• 🌐 <b>سرور / هاست:</b> {host_str}\n"
|
||||||
|
f"• 👤 <b>نام کاربری:</b> {user_str}\n"
|
||||||
|
f"• 🔐 <b>نوع احراز هویت:</b> {auth_str}\n"
|
||||||
|
f"• 📂 <b>مسیر مقصد روی سرور:</b> {path_str}\n\n"
|
||||||
|
f"🛡️ <b>اصول ایمنی پروداکشن:</b>\n"
|
||||||
|
f"├ پوش کدهای محلی منحصراً از شاخه <code>production</code> انجام میشود.\n"
|
||||||
|
f"├ فایلهای دیتابیس (SQLite)، آپلودهای کاربران و فایلهای سشن سرور ریموت دستنخورده باقی میمانند.\n"
|
||||||
|
f"└ تمام درخواستها و لاگها به صورت خودکار در دیتابیس ثبت میشوند.\n\n"
|
||||||
|
f"💡 <i>از دکمههای زیر برای تست اتصال، ارسال کدها (Push)، دریافت کدها (Pull) یا مدیریت مشخصات استفاده کنید.</i>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
f"💻 <b>Project SSH Remote Dev & Server Settings</b>\n\n"
|
||||||
|
f"• 📁 <b>Project:</b> <code>{clean_p}</code>\n"
|
||||||
|
f"• 🌐 <b>SSH Host:</b> {host_str}\n"
|
||||||
|
f"• 👤 <b>Username:</b> {user_str}\n"
|
||||||
|
f"• 🔐 <b>Authentication:</b> {auth_str}\n"
|
||||||
|
f"• 📂 <b>Remote Path:</b> {path_str}\n\n"
|
||||||
|
f"🛡️ <b>Production Safety:</b>\n"
|
||||||
|
f"├ Pushes strictly deploy from <code>production</code> branch.\n"
|
||||||
|
f"├ Remote user uploads, SQLite databases, and session files are preserved.\n"
|
||||||
|
f"└ All operations are audited in SQLite.\n\n"
|
||||||
|
f"💡 <i>Use buttons below to test connection, push, pull, or manage server config.</i>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+121
-23
@@ -8,6 +8,9 @@ from pathlib import Path
|
|||||||
from typing import Optional, Dict, Any, List, Tuple, Set
|
from typing import Optional, Dict, Any, List, Tuple, Set
|
||||||
import ftplib
|
import ftplib
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
|
||||||
logger = logging.getLogger("AGYFTPManager")
|
logger = logging.getLogger("AGYFTPManager")
|
||||||
|
|
||||||
@@ -52,6 +55,10 @@ DEFAULT_FTP_EXCLUDES = [
|
|||||||
"env",
|
"env",
|
||||||
"env/*",
|
"env/*",
|
||||||
|
|
||||||
|
# Dependencies & local packages
|
||||||
|
"node_modules",
|
||||||
|
"node_modules/*",
|
||||||
|
|
||||||
# Logs & temp files
|
# Logs & temp files
|
||||||
"*.log",
|
"*.log",
|
||||||
"tmp",
|
"tmp",
|
||||||
@@ -77,20 +84,23 @@ def should_exclude(rel_path: str, custom_excludes: Optional[List[str]] = None) -
|
|||||||
"""Checks if a relative path matches any exclusion pattern."""
|
"""Checks if a relative path matches any exclusion pattern."""
|
||||||
excludes = DEFAULT_FTP_EXCLUDES + (custom_excludes or [])
|
excludes = DEFAULT_FTP_EXCLUDES + (custom_excludes or [])
|
||||||
norm_path = rel_path.replace("\\", "/").strip("/")
|
norm_path = rel_path.replace("\\", "/").strip("/")
|
||||||
parts = norm_path.split("/")
|
parts = [p for p in norm_path.split("/") if p]
|
||||||
|
|
||||||
for pattern in excludes:
|
for pattern in excludes:
|
||||||
pat = pattern.replace("\\", "/").strip("/")
|
pat = pattern.replace("\\", "/").strip("/")
|
||||||
|
clean_pat = pat.rstrip("/*")
|
||||||
|
|
||||||
# Check against full relative path
|
# Check against full relative path
|
||||||
if fnmatch.fnmatch(norm_path, pat):
|
if fnmatch.fnmatch(norm_path, pat) or fnmatch.fnmatch(norm_path, clean_pat):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# If path starts with directory pattern
|
||||||
|
if norm_path == clean_pat or norm_path.startswith(clean_pat + "/"):
|
||||||
|
return True
|
||||||
|
|
||||||
# Check against individual directory / file parts
|
# Check against individual directory / file parts
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if fnmatch.fnmatch(part, pat):
|
if fnmatch.fnmatch(part, pat) or fnmatch.fnmatch(part, clean_pat):
|
||||||
return True
|
|
||||||
# If pattern is a directory (e.g. .git/* or .git), match if path starts with it
|
|
||||||
clean_pat = pat.rstrip("/*")
|
|
||||||
if norm_path == clean_pat or norm_path.startswith(clean_pat + "/"):
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -109,17 +119,27 @@ class FTPManager:
|
|||||||
timeout: int = 15,
|
timeout: int = 15,
|
||||||
) -> ftplib.FTP:
|
) -> ftplib.FTP:
|
||||||
"""Helper to create and authenticate an FTP or FTPS client."""
|
"""Helper to create and authenticate an FTP or FTPS client."""
|
||||||
|
clean_host = host.strip()
|
||||||
|
clean_user = user.strip() if user else ""
|
||||||
|
clean_pass = password.strip() if password else ""
|
||||||
|
|
||||||
if tls:
|
if tls:
|
||||||
ftp = ftplib.FTP_TLS(timeout=timeout)
|
ftp = ftplib.FTP_TLS(timeout=timeout)
|
||||||
else:
|
else:
|
||||||
ftp = ftplib.FTP(timeout=timeout)
|
ftp = ftplib.FTP(timeout=timeout)
|
||||||
|
|
||||||
ftp.connect(host=host, port=port, timeout=timeout)
|
ftp.encoding = "utf-8"
|
||||||
ftp.login(user=user or "anonymous", passwd=password or "")
|
ftp.connect(host=clean_host, port=int(port), timeout=timeout)
|
||||||
|
|
||||||
if tls and isinstance(ftp, ftplib.FTP_TLS):
|
if tls and isinstance(ftp, ftplib.FTP_TLS):
|
||||||
|
ftp.auth()
|
||||||
|
ftp.login(user=clean_user or "anonymous", passwd=clean_pass)
|
||||||
ftp.prot_p() # Secure data connection
|
ftp.prot_p() # Secure data connection
|
||||||
|
else:
|
||||||
|
ftp.login(user=clean_user or "anonymous", passwd=clean_pass)
|
||||||
|
|
||||||
|
# Force passive mode (standard for firewalls / NAT)
|
||||||
|
ftp.set_pasv(True)
|
||||||
return ftp
|
return ftp
|
||||||
|
|
||||||
async def test_connection(
|
async def test_connection(
|
||||||
@@ -130,39 +150,72 @@ class FTPManager:
|
|||||||
password: str = "",
|
password: str = "",
|
||||||
path: str = "/",
|
path: str = "/",
|
||||||
tls: bool = False,
|
tls: bool = False,
|
||||||
timeout: int = 10,
|
timeout: int = 12,
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""Tests FTP credentials and verifies access to the target remote directory."""
|
"""Tests FTP credentials and verifies access to the target remote directory."""
|
||||||
if not host or not host.strip():
|
if not host or not host.strip():
|
||||||
return False, "آدرس هاست (Host) FTP مشخص نشده است."
|
return False, "آدرس هاست (Host) FTP مشخص نشده است."
|
||||||
|
|
||||||
def _test():
|
def _test():
|
||||||
|
ftp = None
|
||||||
try:
|
try:
|
||||||
ftp = self._create_ftp_client(host.strip(), int(port), user.strip(), password, tls, timeout)
|
ftp = self._create_ftp_client(host.strip(), int(port), user.strip(), password, tls, timeout)
|
||||||
|
|
||||||
|
# Check root PWD
|
||||||
|
init_pwd = ftp.pwd()
|
||||||
|
|
||||||
# Test CWD to target path if specified
|
# Test CWD to target path if specified
|
||||||
target_path = path.strip() if path else "/"
|
target_path = path.strip() if path else "/"
|
||||||
if target_path and target_path != "/":
|
if target_path and target_path != "/":
|
||||||
try:
|
try:
|
||||||
ftp.cwd(target_path)
|
ftp.cwd(target_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pwd = ftp.pwd()
|
curr_pwd = ftp.pwd() if ftp else "/"
|
||||||
|
try:
|
||||||
ftp.quit()
|
ftp.quit()
|
||||||
return False, f"اتصال به FTP برقرار شد، اما مسیر ریموت «{target_path}» یافت نشد (مسیر فعلی: {pwd}): {e}"
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False, f"اتصال به سرور برقرار شد، اما مسیر ریموت «{target_path}» یافت نشد (مسیر فعلی: {curr_pwd}): {e}"
|
||||||
|
|
||||||
pwd = ftp.pwd()
|
final_pwd = ftp.pwd()
|
||||||
|
|
||||||
|
# Test directory listing
|
||||||
|
item_count = 0
|
||||||
try:
|
try:
|
||||||
listing = ftp.nlst()
|
listing = ftp.nlst()
|
||||||
count = len(listing)
|
item_count = len(listing)
|
||||||
except Exception:
|
except Exception:
|
||||||
count = 0
|
try:
|
||||||
|
lines = []
|
||||||
|
ftp.dir(lines.append)
|
||||||
|
item_count = len(lines)
|
||||||
|
except Exception:
|
||||||
|
item_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
ftp.quit()
|
ftp.quit()
|
||||||
return True, f"اتصال با موفقیت برقرار شد. مسیر فعلی: <code>{pwd}</code> (تعداد آیتمها: {count})"
|
except Exception:
|
||||||
except (socket.gaierror, socket.timeout) as e:
|
pass
|
||||||
return False, f"خطای شبکه / نامعتبر بودن آدرس سرور ({host}:{port}): {e}"
|
|
||||||
|
tls_label = " (FTPS/TLS امن)" if tls else " (FTP معمولی)"
|
||||||
|
return True, f"اتصال با موفقیت برقرار شد{tls_label} | مسیر: <code>{final_pwd}</code> | تعداد فایلها و پوشهها: {item_count}"
|
||||||
|
|
||||||
|
except socket.gaierror as e:
|
||||||
|
return False, f"نام دامنه/هاست سرور یافت نشد ({host}): {e}"
|
||||||
|
except (socket.timeout, TimeoutError) as e:
|
||||||
|
return False, f"مهلت زمانی اتصال به سرور به پایان رسید (Timeout روی پورت {port}): {e}"
|
||||||
|
except ConnectionRefusedError as e:
|
||||||
|
return False, f"اتصال توسط سرور رد شد (پورت {port} بسته است یا FTP روی آن فعال نیست): {e}"
|
||||||
except ftplib.error_perm as e:
|
except ftplib.error_perm as e:
|
||||||
return False, f"خطای احراز هویت / دسترسی FTP: {e}"
|
return False, f"خطای نام کاربری یا رمز عبور (دسترسی نامعتبر): {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"خطا در برقراری ارتباط با FTP: {e}"
|
return False, f"خطا در برقراری ارتباط با FTP: {e}"
|
||||||
|
finally:
|
||||||
|
if ftp:
|
||||||
|
try:
|
||||||
|
ftp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return await asyncio.to_thread(_test)
|
return await asyncio.to_thread(_test)
|
||||||
|
|
||||||
@@ -177,11 +230,16 @@ class FTPManager:
|
|||||||
tls: bool = False,
|
tls: bool = False,
|
||||||
custom_excludes: Optional[List[str]] = None,
|
custom_excludes: Optional[List[str]] = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
|
branch: str = "production",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Deploys files from workspace_path to the remote FTP server cleanly.
|
Deploys files from the specified git branch (default: production) to the remote FTP server cleanly.
|
||||||
|
Extracts files directly from the git branch to ensure exact production code is deployed.
|
||||||
Skips all temporary, git, session, and junk files.
|
Skips all temporary, git, session, and junk files.
|
||||||
"""
|
"""
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
|
||||||
ws = Path(workspace_path).expanduser().resolve()
|
ws = Path(workspace_path).expanduser().resolve()
|
||||||
if not ws.exists() or not ws.is_dir():
|
if not ws.exists() or not ws.is_dir():
|
||||||
return {
|
return {
|
||||||
@@ -199,8 +257,42 @@ class FTPManager:
|
|||||||
uploaded_files: List[str] = []
|
uploaded_files: List[str] = []
|
||||||
skipped_files: List[str] = []
|
skipped_files: List[str] = []
|
||||||
total_bytes = 0
|
total_bytes = 0
|
||||||
|
temp_export_dir = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# 1. Determine source directory: if git exists, export the exact production branch
|
||||||
|
source_dir = ws
|
||||||
|
if (ws / ".git").exists():
|
||||||
|
try:
|
||||||
|
# Check if branch exists
|
||||||
|
branches_proc = subprocess.run(
|
||||||
|
["git", "branch", "--list", branch],
|
||||||
|
cwd=str(ws),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
target_branch = branch if branch in branches_proc.stdout else "HEAD"
|
||||||
|
|
||||||
|
temp_export_dir = Path(tempfile.mkdtemp(prefix="ftp_deploy_prod_"))
|
||||||
|
# Use git archive to cleanly extract files from the target branch
|
||||||
|
archive_proc = subprocess.Popen(
|
||||||
|
["git", "archive", target_branch],
|
||||||
|
cwd=str(ws),
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
tar_proc = subprocess.Popen(
|
||||||
|
["tar", "-x", "-C", str(temp_export_dir)],
|
||||||
|
stdin=archive_proc.stdout,
|
||||||
|
)
|
||||||
|
archive_proc.stdout.close()
|
||||||
|
tar_proc.communicate()
|
||||||
|
|
||||||
|
if tar_proc.returncode == 0:
|
||||||
|
source_dir = temp_export_dir
|
||||||
|
except Exception as ge:
|
||||||
|
logger.warning(f"Git archive export failed for {branch} (falling back to workspace): {ge}")
|
||||||
|
source_dir = ws
|
||||||
|
|
||||||
ftp = self._create_ftp_client(host.strip(), int(port), user.strip(), password, tls, timeout=20)
|
ftp = self._create_ftp_client(host.strip(), int(port), user.strip(), password, tls, timeout=20)
|
||||||
|
|
||||||
# Navigate or create remote root path
|
# Navigate or create remote root path
|
||||||
@@ -230,11 +322,11 @@ class FTPManager:
|
|||||||
|
|
||||||
ensure_remote_dir(target_root)
|
ensure_remote_dir(target_root)
|
||||||
|
|
||||||
# Scan workspace
|
# Scan source directory
|
||||||
all_files_to_upload: List[Tuple[Path, str]] = [] # (local_path, rel_path)
|
all_files_to_upload: List[Tuple[Path, str]] = [] # (local_path, rel_path)
|
||||||
|
|
||||||
for root, dirs, files in os.walk(str(ws)):
|
for root, dirs, files in os.walk(str(source_dir)):
|
||||||
rel_dir = os.path.relpath(root, str(ws))
|
rel_dir = os.path.relpath(root, str(source_dir))
|
||||||
if rel_dir == ".":
|
if rel_dir == ".":
|
||||||
rel_dir = ""
|
rel_dir = ""
|
||||||
|
|
||||||
@@ -309,6 +401,12 @@ class FTPManager:
|
|||||||
"duration": round(time.time() - start_time, 2),
|
"duration": round(time.time() - start_time, 2),
|
||||||
"uploaded_list": uploaded_files,
|
"uploaded_list": uploaded_files,
|
||||||
}
|
}
|
||||||
|
finally:
|
||||||
|
if temp_export_dir and Path(temp_export_dir).exists():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(temp_export_dir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return await asyncio.to_thread(_run_deploy)
|
return await asyncio.to_thread(_run_deploy)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -12,6 +13,11 @@ from config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger("AGYGitManager")
|
logger = logging.getLogger("AGYGitManager")
|
||||||
|
|
||||||
|
def escape_html(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return html.escape(str(text), quote=False)
|
||||||
|
|
||||||
DEFAULT_GITIGNORE = """# Byte-compiled / optimized / DLL files
|
DEFAULT_GITIGNORE = """# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, List, Tuple
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger("AGYRemoteAudit")
|
||||||
|
|
||||||
|
DATA_DIR = Path("/root/telegram-agy-bot/data")
|
||||||
|
DB_PATH = DATA_DIR / "memory.db"
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteAuditLogger:
|
||||||
|
"""Manages audit logging for SSH, FTP, and remote operations into SQLite."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path = DB_PATH):
|
||||||
|
self.db_path = db_path
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _get_connection(self) -> sqlite3.Connection:
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(self.db_path), timeout=15.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL;")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
"""Initializes remote_audit_logs table and indexes."""
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS remote_audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp REAL NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
project_name TEXT NOT NULL,
|
||||||
|
service_type TEXT NOT NULL, -- 'ssh', 'ftp'
|
||||||
|
action_type TEXT NOT NULL, -- 'push', 'pull', 'exec', 'test_connection', 'update_config'
|
||||||
|
requester TEXT NOT NULL, -- 'ai_agent', 'user_button', 'user_command'
|
||||||
|
details TEXT, -- command or parameters (never contains passwords)
|
||||||
|
status TEXT NOT NULL, -- 'success', 'failed', 'running'
|
||||||
|
result_summary TEXT, -- short summary or error message
|
||||||
|
duration_ms REAL DEFAULT 0.0
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_remote_audit_lookup
|
||||||
|
ON remote_audit_logs(user_id, project_name, service_type, timestamp DESC);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error initializing remote_audit_logs table: {e}")
|
||||||
|
|
||||||
|
def log_operation(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
project_name: str,
|
||||||
|
service_type: str,
|
||||||
|
action_type: str,
|
||||||
|
requester: str,
|
||||||
|
status: str,
|
||||||
|
details: Optional[str] = None,
|
||||||
|
result_summary: Optional[str] = None,
|
||||||
|
duration_ms: float = 0.0,
|
||||||
|
timestamp: Optional[float] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Logs a remote operation into the audit database."""
|
||||||
|
now_ts = timestamp or time.time()
|
||||||
|
dt_str = datetime.fromtimestamp(now_ts).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
# Sanitize details (ensure no raw passwords accidentally entered)
|
||||||
|
safe_details = details or ""
|
||||||
|
if "password" in safe_details.lower():
|
||||||
|
safe_details = "[FILTERED DETAILS]"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO remote_audit_logs (
|
||||||
|
timestamp, created_at, user_id, project_name,
|
||||||
|
service_type, action_type, requester, details,
|
||||||
|
status, result_summary, duration_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
now_ts,
|
||||||
|
dt_str,
|
||||||
|
int(user_id),
|
||||||
|
str(project_name),
|
||||||
|
str(service_type).lower(),
|
||||||
|
str(action_type).lower(),
|
||||||
|
str(requester).lower(),
|
||||||
|
safe_details[:1000] if safe_details else None,
|
||||||
|
str(status).lower(),
|
||||||
|
str(result_summary)[:1000] if result_summary else None,
|
||||||
|
float(duration_ms),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.lastrowid
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error writing to remote_audit_logs: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_recent_logs(
|
||||||
|
self,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
project_name: Optional[str] = None,
|
||||||
|
service_type: Optional[str] = None,
|
||||||
|
limit: int = 15,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Retrieves recent audit log records."""
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
query = "SELECT * FROM remote_audit_logs WHERE 1=1"
|
||||||
|
params: List[Any] = []
|
||||||
|
if user_id is not None:
|
||||||
|
query += " AND user_id = ?"
|
||||||
|
params.append(user_id)
|
||||||
|
if project_name:
|
||||||
|
query += " AND project_name = ?"
|
||||||
|
params.append(project_name)
|
||||||
|
if service_type:
|
||||||
|
query += " AND service_type = ?"
|
||||||
|
params.append(service_type)
|
||||||
|
|
||||||
|
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||||
|
params.append(int(limit))
|
||||||
|
|
||||||
|
cur = conn.execute(query, params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching remote_audit_logs: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def format_logs_for_tg(
|
||||||
|
self,
|
||||||
|
logs: List[Dict[str, Any]],
|
||||||
|
project_name: str,
|
||||||
|
is_fa: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Formats audit logs into a clean Telegram HTML message."""
|
||||||
|
if not logs:
|
||||||
|
if is_fa:
|
||||||
|
return (
|
||||||
|
f"📜 <b>تاریخچه و لاگ عملیات ریموت</b>\n\n"
|
||||||
|
f"• 📁 <b>پروژه:</b> <code>{project_name}</code>\n\n"
|
||||||
|
f"ℹ️ <i>هنوز هیچ عملیات SSH یا FTP برای این پروژه ثبت نشده است.</i>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
f"📜 <b>Remote Operations Audit Log</b>\n\n"
|
||||||
|
f"• 📁 <b>Project:</b> <code>{project_name}</code>\n\n"
|
||||||
|
f"ℹ️ <i>No SSH or FTP operations logged for this project yet.</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if is_fa:
|
||||||
|
lines.append(f"📜 <b>تاریخچه عملیات ریموت و SSH/FTP</b>")
|
||||||
|
lines.append(f"• 📁 <b>پروژه:</b> <code>{project_name}</code>")
|
||||||
|
lines.append(f"• 📊 <b>تعداد لاگهای اخیر:</b> <code>{len(logs)}</code>\n")
|
||||||
|
else:
|
||||||
|
lines.append(f"📜 <b>Remote Operations & SSH/FTP Audit Log</b>")
|
||||||
|
lines.append(f"• 📁 <b>Project:</b> <code>{project_name}</code>")
|
||||||
|
lines.append(f"• 📊 <b>Recent records:</b> <code>{len(logs)}</code>\n")
|
||||||
|
|
||||||
|
for idx, log in enumerate(logs, 1):
|
||||||
|
st = log.get("status", "unknown")
|
||||||
|
st_icon = "✅" if st == "success" else ("❌" if st == "failed" else "⏳")
|
||||||
|
srv = log.get("service_type", "").upper()
|
||||||
|
act = log.get("action_type", "")
|
||||||
|
req = log.get("requester", "")
|
||||||
|
req_label = "🤖 AI" if "ai" in req else ("👤 کاربر" if is_fa else "👤 User")
|
||||||
|
dt = log.get("created_at", "")
|
||||||
|
dur = log.get("duration_ms", 0.0)
|
||||||
|
dur_str = f" ({dur:.1f}ms)" if dur > 0 else ""
|
||||||
|
res = log.get("result_summary") or ""
|
||||||
|
det = log.get("details") or ""
|
||||||
|
|
||||||
|
act_fa = {
|
||||||
|
"push": "پوش به پروداکشن 🚀",
|
||||||
|
"pull": "دریافت از سرور 📥",
|
||||||
|
"exec": "اجرای فرمان 💻",
|
||||||
|
"test_connection": "تست اتصال 🔍",
|
||||||
|
"update_config": "ویرایش تنظیمات ⚙️",
|
||||||
|
}.get(act, act)
|
||||||
|
|
||||||
|
act_display = act_fa if is_fa else act.capitalize()
|
||||||
|
|
||||||
|
item_text = f"<b>{idx}. {st_icon} [{srv}] {act_display}</b> | {req_label}\n"
|
||||||
|
item_text += f" 🕒 <code>{dt}</code>{dur_str}\n"
|
||||||
|
if det:
|
||||||
|
item_text += f" 📝 <code>{det[:60]}</code>\n"
|
||||||
|
if res:
|
||||||
|
item_text += f" 💬 <i>{res[:100]}</i>\n"
|
||||||
|
|
||||||
|
lines.append(item_text)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton instance
|
||||||
|
remote_audit = RemoteAuditLogger()
|
||||||
+270
-48
File diff suppressed because one or more lines are too long
@@ -0,0 +1,719 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import fnmatch
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, List, Tuple, Set
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
import io
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from remote_audit import remote_audit
|
||||||
|
|
||||||
|
logger = logging.getLogger("AGYSSHManager")
|
||||||
|
|
||||||
|
# Standard patterns for junk, temporary, local configs, and production user data that must NEVER be overwritten or pushed
|
||||||
|
DEFAULT_SSH_EXCLUDES = [
|
||||||
|
# Git and version control
|
||||||
|
".git",
|
||||||
|
".git/*",
|
||||||
|
".gitignore",
|
||||||
|
".gitattributes",
|
||||||
|
".gitmodules",
|
||||||
|
|
||||||
|
# Environment & local secret files
|
||||||
|
".env",
|
||||||
|
".env.*",
|
||||||
|
"*.env",
|
||||||
|
"*.pem",
|
||||||
|
"*.key",
|
||||||
|
|
||||||
|
# User databases and data stores (never overwrite production sqlite / db files!)
|
||||||
|
"*.db",
|
||||||
|
"*.db-shm",
|
||||||
|
"*.db-wal",
|
||||||
|
"*.sqlite",
|
||||||
|
"*.sqlite3",
|
||||||
|
"data/*.db",
|
||||||
|
"data/*.sqlite",
|
||||||
|
|
||||||
|
# Dynamic uploads & media directories (production user uploads must never be touched)
|
||||||
|
"uploads",
|
||||||
|
"uploads/*",
|
||||||
|
"*/uploads",
|
||||||
|
"*/uploads/*",
|
||||||
|
"media",
|
||||||
|
"media/*",
|
||||||
|
"storage/uploads",
|
||||||
|
"storage/uploads/*",
|
||||||
|
"storage/framework",
|
||||||
|
"storage/framework/*",
|
||||||
|
|
||||||
|
# AI & Bot internal session / agent configs
|
||||||
|
".agents",
|
||||||
|
".agents/*",
|
||||||
|
".gemini",
|
||||||
|
".gemini/*",
|
||||||
|
"sessions_data",
|
||||||
|
"sessions_data/*",
|
||||||
|
"uploads_temp",
|
||||||
|
"uploads_temp/*",
|
||||||
|
|
||||||
|
# Python cache & virtual environments
|
||||||
|
"__pycache__",
|
||||||
|
"__pycache__/*",
|
||||||
|
"*.pyc",
|
||||||
|
"*.pyo",
|
||||||
|
"*.pyd",
|
||||||
|
".venv",
|
||||||
|
".venv/*",
|
||||||
|
"venv",
|
||||||
|
"venv/*",
|
||||||
|
"env",
|
||||||
|
"env/*",
|
||||||
|
|
||||||
|
# Node.js dependencies
|
||||||
|
"node_modules",
|
||||||
|
"node_modules/*",
|
||||||
|
|
||||||
|
# Logs & temporary files
|
||||||
|
"*.log",
|
||||||
|
"logs",
|
||||||
|
"logs/*",
|
||||||
|
"tmp",
|
||||||
|
"tmp/*",
|
||||||
|
"temp",
|
||||||
|
"temp/*",
|
||||||
|
|
||||||
|
# IDE & OS files
|
||||||
|
".DS_Store",
|
||||||
|
"Thumbs.db",
|
||||||
|
"desktop.ini",
|
||||||
|
".idea",
|
||||||
|
".idea/*",
|
||||||
|
".vscode",
|
||||||
|
".vscode/*",
|
||||||
|
"*.swp",
|
||||||
|
"*.swo",
|
||||||
|
"*~",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def should_exclude_ssh(rel_path: str, custom_excludes: Optional[List[str]] = None) -> bool:
|
||||||
|
"""Checks if a relative path matches any exclusion pattern for SSH sync."""
|
||||||
|
excludes = DEFAULT_SSH_EXCLUDES + (custom_excludes or [])
|
||||||
|
norm_path = rel_path.replace("\\", "/").strip("/")
|
||||||
|
parts = [p for p in norm_path.split("/") if p]
|
||||||
|
|
||||||
|
for pattern in excludes:
|
||||||
|
pat = pattern.replace("\\", "/").strip("/")
|
||||||
|
clean_pat = pat.rstrip("/*")
|
||||||
|
|
||||||
|
if fnmatch.fnmatch(norm_path, pat) or fnmatch.fnmatch(norm_path, clean_pat):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if norm_path == clean_pat or norm_path.startswith(clean_pat + "/"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if fnmatch.fnmatch(part, pat) or fnmatch.fnmatch(part, clean_pat):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SSHManager:
|
||||||
|
"""Manages SSH connections, remote dev synchronization, execution, and audit logging."""
|
||||||
|
|
||||||
|
def _create_ssh_client(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = 22,
|
||||||
|
user: str = "",
|
||||||
|
password: Optional[str] = None,
|
||||||
|
key_content_or_path: Optional[str] = None,
|
||||||
|
timeout: int = 15,
|
||||||
|
) -> paramiko.SSHClient:
|
||||||
|
"""Helper to create and authenticate a paramiko SSHClient."""
|
||||||
|
clean_host = host.strip()
|
||||||
|
clean_user = user.strip() if user else "root"
|
||||||
|
clean_pass = password.strip() if password else None
|
||||||
|
clean_port = int(port) if port else 22
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
|
||||||
|
pkey = None
|
||||||
|
if key_content_or_path and key_content_or_path.strip():
|
||||||
|
raw_k = key_content_or_path.strip()
|
||||||
|
# Check if key is a file path
|
||||||
|
if os.path.isfile(raw_k):
|
||||||
|
try:
|
||||||
|
pkey = paramiko.RSAKey.from_private_key_file(raw_k, password=clean_pass)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
pkey = paramiko.Ed25519Key.from_private_key_file(raw_k, password=clean_pass)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
pkey = paramiko.ECDSAKey.from_private_key_file(raw_k, password=clean_pass)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not load private key from file {raw_k}: {e}")
|
||||||
|
else:
|
||||||
|
# Key is provided as string text
|
||||||
|
key_file_obj = io.StringIO(raw_k)
|
||||||
|
for key_cls in (paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.DSSKey):
|
||||||
|
try:
|
||||||
|
key_file_obj.seek(0)
|
||||||
|
pkey = key_cls.from_private_key(key_file_obj, password=clean_pass)
|
||||||
|
if pkey:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
connect_kwargs: Dict[str, Any] = {
|
||||||
|
"hostname": clean_host,
|
||||||
|
"port": clean_port,
|
||||||
|
"username": clean_user,
|
||||||
|
"timeout": timeout,
|
||||||
|
"banner_timeout": timeout,
|
||||||
|
"auth_timeout": timeout,
|
||||||
|
"allow_agent": False,
|
||||||
|
"look_for_keys": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkey:
|
||||||
|
connect_kwargs["pkey"] = pkey
|
||||||
|
elif clean_pass:
|
||||||
|
connect_kwargs["password"] = clean_pass
|
||||||
|
else:
|
||||||
|
# Try agent / default keys if neither provided
|
||||||
|
connect_kwargs["allow_agent"] = True
|
||||||
|
connect_kwargs["look_for_keys"] = True
|
||||||
|
|
||||||
|
client.connect(**connect_kwargs)
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def test_connection(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = 22,
|
||||||
|
user: str = "",
|
||||||
|
password: Optional[str] = None,
|
||||||
|
key: Optional[str] = None,
|
||||||
|
path: str = "/",
|
||||||
|
timeout: int = 12,
|
||||||
|
user_id: int = 0,
|
||||||
|
project_name: str = "",
|
||||||
|
requester: str = "user_button",
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""Tests SSH connection and checks remote path accessibility."""
|
||||||
|
start_t = time.time()
|
||||||
|
if not host or not host.strip():
|
||||||
|
return False, "آدرس هاست (Host) سرور SSH مشخص نشده است."
|
||||||
|
|
||||||
|
def _test():
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
client = self._create_ssh_client(
|
||||||
|
host=host.strip(),
|
||||||
|
port=port,
|
||||||
|
user=user.strip(),
|
||||||
|
password=password,
|
||||||
|
key_content_or_path=key,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run simple probe command
|
||||||
|
stdin, stdout, stderr = client.exec_command("uname -sr || echo Linux", timeout=timeout)
|
||||||
|
os_info = stdout.read().decode("utf-8", errors="ignore").strip()
|
||||||
|
|
||||||
|
# Test SFTP session and target path
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
target_path = (path or "/").strip()
|
||||||
|
path_status = "موجود است"
|
||||||
|
item_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
sftp.stat(target_path)
|
||||||
|
try:
|
||||||
|
dir_items = sftp.listdir(target_path)
|
||||||
|
item_count = len(dir_items)
|
||||||
|
except Exception:
|
||||||
|
item_count = 0
|
||||||
|
except IOError:
|
||||||
|
path_status = "موجود نیست (در اولین استقرار به صورت خودکار ایجاد میشود)"
|
||||||
|
|
||||||
|
sftp.close()
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"اتصال SSH با موفقیت برقرار شد ✅\n"
|
||||||
|
f"• 🖥️ <b>سیستمعامل:</b> <code>{os_info}</code>\n"
|
||||||
|
f"• 📂 <b>مسیر ریموت:</b> <code>{target_path}</code> ({path_status})\n"
|
||||||
|
f"• 📊 <b>تعداد آیتمها در مسیر:</b> <code>{item_count}</code>"
|
||||||
|
)
|
||||||
|
return True, msg
|
||||||
|
|
||||||
|
except socket.gaierror as e:
|
||||||
|
return False, f"نام دامنه یا IP سرور یافت نشد ({host}): {e}"
|
||||||
|
except (socket.timeout, TimeoutError) as e:
|
||||||
|
return False, f"مهلت زمانی اتصال SSH به پایان رسید (Timeout روی پورت {port}): {e}"
|
||||||
|
except ConnectionRefusedError as e:
|
||||||
|
return False, f"اتصال SSH توسط سرور رد شد (پورت {port} مسدود است یا SSH اجرا نمیشود)."
|
||||||
|
except paramiko.AuthenticationException as e:
|
||||||
|
return False, f"خطای احراز هویت SSH: نام کاربری، رمز عبور یا کلید نامعتبر است ({e})."
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"خطا در برقراری ارتباط SSH: {e}"
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
success, result_msg = await asyncio.to_thread(_test)
|
||||||
|
dur = (time.time() - start_t) * 1000
|
||||||
|
|
||||||
|
# Record audit log
|
||||||
|
remote_audit.log_operation(
|
||||||
|
user_id=user_id,
|
||||||
|
project_name=project_name or "unknown",
|
||||||
|
service_type="ssh",
|
||||||
|
action_type="test_connection",
|
||||||
|
requester=requester,
|
||||||
|
status="success" if success else "failed",
|
||||||
|
details=f"Host: {host}:{port}, Path: {path}",
|
||||||
|
result_summary=result_msg[:200],
|
||||||
|
duration_ms=dur,
|
||||||
|
)
|
||||||
|
|
||||||
|
return success, result_msg
|
||||||
|
|
||||||
|
async def push_project(
|
||||||
|
self,
|
||||||
|
workspace_path: str,
|
||||||
|
host: str,
|
||||||
|
port: int = 22,
|
||||||
|
user: str = "",
|
||||||
|
password: Optional[str] = None,
|
||||||
|
key: Optional[str] = None,
|
||||||
|
remote_path: str = "/",
|
||||||
|
branch: str = "production",
|
||||||
|
custom_excludes: Optional[List[str]] = None,
|
||||||
|
user_id: int = 0,
|
||||||
|
project_name: str = "",
|
||||||
|
requester: str = "user_button",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Pushes files from the git branch (strictly 'production') to remote server via SFTP.
|
||||||
|
Ensures existing production user data, SQLite databases, and dynamic uploads are NEVER modified.
|
||||||
|
"""
|
||||||
|
start_t = time.time()
|
||||||
|
ws = Path(workspace_path).expanduser().resolve()
|
||||||
|
if not ws.exists() or not ws.is_dir():
|
||||||
|
err = f"دایرکتوری محلی پروژه در مسیر {workspace_path} یافت نشد."
|
||||||
|
remote_audit.log_operation(
|
||||||
|
user_id=user_id,
|
||||||
|
project_name=project_name,
|
||||||
|
service_type="ssh",
|
||||||
|
action_type="push",
|
||||||
|
requester=requester,
|
||||||
|
status="failed",
|
||||||
|
details=f"Push branch={branch} to {host}:{port}{remote_path}",
|
||||||
|
result_summary=err,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": err,
|
||||||
|
"files_uploaded": 0,
|
||||||
|
"files_skipped": 0,
|
||||||
|
"bytes_transferred": 0,
|
||||||
|
"duration": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _run_push():
|
||||||
|
client = None
|
||||||
|
temp_export_dir = None
|
||||||
|
uploaded_files = []
|
||||||
|
skipped_files = []
|
||||||
|
total_bytes = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Extract strictly from production git branch
|
||||||
|
source_dir = ws
|
||||||
|
if (ws / ".git").exists():
|
||||||
|
try:
|
||||||
|
branches_proc = subprocess.run(
|
||||||
|
["git", "branch", "--list", branch],
|
||||||
|
cwd=str(ws),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
target_branch = branch if branch in branches_proc.stdout else "HEAD"
|
||||||
|
|
||||||
|
temp_export_dir = Path(tempfile.mkdtemp(prefix="ssh_push_prod_"))
|
||||||
|
archive_proc = subprocess.Popen(
|
||||||
|
["git", "archive", target_branch],
|
||||||
|
cwd=str(ws),
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
tar_proc = subprocess.Popen(
|
||||||
|
["tar", "-x", "-C", str(temp_export_dir)],
|
||||||
|
stdin=archive_proc.stdout,
|
||||||
|
)
|
||||||
|
archive_proc.stdout.close()
|
||||||
|
tar_proc.communicate()
|
||||||
|
|
||||||
|
if tar_proc.returncode == 0:
|
||||||
|
source_dir = temp_export_dir
|
||||||
|
except Exception as ge:
|
||||||
|
logger.warning(f"Git archive export failed for {branch} (using ws): {ge}")
|
||||||
|
source_dir = ws
|
||||||
|
|
||||||
|
client = self._create_ssh_client(
|
||||||
|
host=host.strip(),
|
||||||
|
port=port,
|
||||||
|
user=user.strip(),
|
||||||
|
password=password,
|
||||||
|
key_content_or_path=key,
|
||||||
|
timeout=25,
|
||||||
|
)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
# Prepare remote path
|
||||||
|
target_root = (remote_path or "/").strip().replace("\\", "/")
|
||||||
|
if not target_root.startswith("/"):
|
||||||
|
target_root = "/" + target_root
|
||||||
|
target_root = target_root.rstrip("/")
|
||||||
|
if not target_root:
|
||||||
|
target_root = "/"
|
||||||
|
|
||||||
|
def ensure_remote_dir(r_dir: str):
|
||||||
|
if r_dir in ("/", ""):
|
||||||
|
return
|
||||||
|
parts = [p for p in r_dir.split("/") if p]
|
||||||
|
curr = ""
|
||||||
|
for p in parts:
|
||||||
|
curr += "/" + p
|
||||||
|
try:
|
||||||
|
sftp.stat(curr)
|
||||||
|
except IOError:
|
||||||
|
try:
|
||||||
|
sftp.mkdir(curr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ensure_remote_dir(target_root)
|
||||||
|
|
||||||
|
# Scan and filter local files
|
||||||
|
for root, dirs, files in os.walk(str(source_dir)):
|
||||||
|
rel_dir = os.path.relpath(root, str(source_dir))
|
||||||
|
if rel_dir == ".":
|
||||||
|
rel_dir = ""
|
||||||
|
|
||||||
|
dirs_to_keep = []
|
||||||
|
for d in dirs:
|
||||||
|
dir_rel = f"{rel_dir}/{d}".strip("/")
|
||||||
|
if should_exclude_ssh(dir_rel, custom_excludes):
|
||||||
|
skipped_files.append(dir_rel + "/")
|
||||||
|
else:
|
||||||
|
dirs_to_keep.append(d)
|
||||||
|
dirs[:] = dirs_to_keep
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
file_rel = f"{rel_dir}/{f}".strip("/")
|
||||||
|
if should_exclude_ssh(file_rel, custom_excludes):
|
||||||
|
skipped_files.append(file_rel)
|
||||||
|
else:
|
||||||
|
local_file = Path(root) / f
|
||||||
|
remote_file_path = f"{target_root}/{file_rel}".replace("//", "/")
|
||||||
|
remote_file_dir = os.path.dirname(remote_file_path)
|
||||||
|
|
||||||
|
ensure_remote_dir(remote_file_dir)
|
||||||
|
sftp.put(str(local_file), remote_file_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
f_size = local_file.stat().st_size
|
||||||
|
total_bytes += f_size
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
uploaded_files.append(file_rel)
|
||||||
|
|
||||||
|
sftp.close()
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
dur = time.time() - start_t
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"files_uploaded": len(uploaded_files),
|
||||||
|
"files_skipped": len(skipped_files),
|
||||||
|
"bytes_transferred": total_bytes,
|
||||||
|
"duration": dur,
|
||||||
|
"uploaded_list": uploaded_files,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSH push failed: {e}", exc_info=True)
|
||||||
|
dur = time.time() - start_t
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"خطا در ارسال فایلها به سرور SSH: {e}",
|
||||||
|
"files_uploaded": len(uploaded_files),
|
||||||
|
"files_skipped": len(skipped_files),
|
||||||
|
"bytes_transferred": total_bytes,
|
||||||
|
"duration": dur,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if temp_export_dir and os.path.exists(temp_export_dir):
|
||||||
|
shutil.rmtree(temp_export_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
res = await asyncio.to_thread(_run_push)
|
||||||
|
|
||||||
|
# Audit log record
|
||||||
|
status_str = "success" if res.get("success") else "failed"
|
||||||
|
summary_str = f"Uploaded {res.get('files_uploaded', 0)} files ({res.get('bytes_transferred', 0)} bytes)" if res.get("success") else str(res.get("error", "Error"))
|
||||||
|
remote_audit.log_operation(
|
||||||
|
user_id=user_id,
|
||||||
|
project_name=project_name,
|
||||||
|
service_type="ssh",
|
||||||
|
action_type="push",
|
||||||
|
requester=requester,
|
||||||
|
status=status_str,
|
||||||
|
details=f"Push to {host}:{port}{remote_path} (Branch: {branch})",
|
||||||
|
result_summary=summary_str[:200],
|
||||||
|
duration_ms=res.get("duration", 0.0) * 1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def pull_project(
|
||||||
|
self,
|
||||||
|
workspace_path: str,
|
||||||
|
host: str,
|
||||||
|
port: int = 22,
|
||||||
|
user: str = "",
|
||||||
|
password: Optional[str] = None,
|
||||||
|
key: Optional[str] = None,
|
||||||
|
remote_path: str = "/",
|
||||||
|
branch: str = "production",
|
||||||
|
custom_excludes: Optional[List[str]] = None,
|
||||||
|
user_id: int = 0,
|
||||||
|
project_name: str = "",
|
||||||
|
requester: str = "user_button",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Pulls files from the remote SSH server into local workspace / production branch.
|
||||||
|
Skips remote database and user upload files according to exclusion rules.
|
||||||
|
"""
|
||||||
|
start_t = time.time()
|
||||||
|
ws = Path(workspace_path).expanduser().resolve()
|
||||||
|
ws.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _run_pull():
|
||||||
|
client = None
|
||||||
|
downloaded_files = []
|
||||||
|
skipped_files = []
|
||||||
|
total_bytes = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = self._create_ssh_client(
|
||||||
|
host=host.strip(),
|
||||||
|
port=port,
|
||||||
|
user=user.strip(),
|
||||||
|
password=password,
|
||||||
|
key_content_or_path=key,
|
||||||
|
timeout=25,
|
||||||
|
)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
target_root = (remote_path or "/").strip().replace("\\", "/").rstrip("/")
|
||||||
|
if not target_root:
|
||||||
|
target_root = "/"
|
||||||
|
|
||||||
|
def recursive_download(r_dir: str, rel_dir: str = ""):
|
||||||
|
nonlocal total_bytes
|
||||||
|
try:
|
||||||
|
entries = sftp.listdir_attr(r_dir)
|
||||||
|
except IOError as e:
|
||||||
|
logger.warning(f"Cannot list remote dir {r_dir}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
fname = entry.filename
|
||||||
|
if fname in (".", ".."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cur_rel = f"{rel_dir}/{fname}".strip("/")
|
||||||
|
cur_remote = f"{r_dir}/{fname}".replace("//", "/")
|
||||||
|
|
||||||
|
# Check if directory
|
||||||
|
import stat
|
||||||
|
is_dir = stat.S_ISDIR(entry.st_mode)
|
||||||
|
|
||||||
|
if should_exclude_ssh(cur_rel if not is_dir else cur_rel + "/", custom_excludes):
|
||||||
|
skipped_files.append(cur_rel + ("/" if is_dir else ""))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_dir:
|
||||||
|
local_sub = ws / cur_rel
|
||||||
|
local_sub.mkdir(parents=True, exist_ok=True)
|
||||||
|
recursive_download(cur_remote, cur_rel)
|
||||||
|
else:
|
||||||
|
local_dest = ws / cur_rel
|
||||||
|
local_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sftp.get(cur_remote, str(local_dest))
|
||||||
|
total_bytes += entry.st_size
|
||||||
|
downloaded_files.append(cur_rel)
|
||||||
|
|
||||||
|
recursive_download(target_root)
|
||||||
|
sftp.close()
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
dur = time.time() - start_t
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"files_downloaded": len(downloaded_files),
|
||||||
|
"files_skipped": len(skipped_files),
|
||||||
|
"bytes_transferred": total_bytes,
|
||||||
|
"duration": dur,
|
||||||
|
"downloaded_list": downloaded_files,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSH pull failed: {e}", exc_info=True)
|
||||||
|
dur = time.time() - start_t
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"خطا در دریافت فایلها از سرور SSH: {e}",
|
||||||
|
"files_downloaded": len(downloaded_files),
|
||||||
|
"files_skipped": len(skipped_files),
|
||||||
|
"bytes_transferred": total_bytes,
|
||||||
|
"duration": dur,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
res = await asyncio.to_thread(_run_pull)
|
||||||
|
|
||||||
|
# Audit log record
|
||||||
|
status_str = "success" if res.get("success") else "failed"
|
||||||
|
summary_str = f"Downloaded {res.get('files_downloaded', 0)} files ({res.get('bytes_transferred', 0)} bytes)" if res.get("success") else str(res.get("error", "Error"))
|
||||||
|
remote_audit.log_operation(
|
||||||
|
user_id=user_id,
|
||||||
|
project_name=project_name,
|
||||||
|
service_type="ssh",
|
||||||
|
action_type="pull",
|
||||||
|
requester=requester,
|
||||||
|
status=status_str,
|
||||||
|
details=f"Pull from {host}:{port}{remote_path} to {workspace_path}",
|
||||||
|
result_summary=summary_str[:200],
|
||||||
|
duration_ms=res.get("duration", 0.0) * 1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def execute_command(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = 22,
|
||||||
|
user: str = "",
|
||||||
|
password: Optional[str] = None,
|
||||||
|
key: Optional[str] = None,
|
||||||
|
remote_path: str = "/",
|
||||||
|
command: str = "",
|
||||||
|
timeout: int = 60,
|
||||||
|
user_id: int = 0,
|
||||||
|
project_name: str = "",
|
||||||
|
requester: str = "user_button",
|
||||||
|
) -> Tuple[bool, str, int]:
|
||||||
|
"""
|
||||||
|
Executes a bash command remotely inside the specified remote_path directory.
|
||||||
|
"""
|
||||||
|
start_t = time.time()
|
||||||
|
if not command or not command.strip():
|
||||||
|
return False, "دستور اجرایی خالی است.", -1
|
||||||
|
|
||||||
|
def _run_exec():
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
client = self._create_ssh_client(
|
||||||
|
host=host.strip(),
|
||||||
|
port=port,
|
||||||
|
user=user.strip(),
|
||||||
|
password=password,
|
||||||
|
key_content_or_path=key,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
cd_prefix = f"cd {remote_path} && " if remote_path and remote_path != "/" else ""
|
||||||
|
full_cmd = f"bash -c {subprocess.list2cmdline([cd_prefix + command])}" if cd_prefix else command
|
||||||
|
|
||||||
|
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=timeout)
|
||||||
|
out = stdout.read().decode("utf-8", errors="ignore")
|
||||||
|
err = stderr.read().decode("utf-8", errors="ignore")
|
||||||
|
exit_code = stdout.channel.recv_exit_status()
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
combined = ""
|
||||||
|
if out:
|
||||||
|
combined += out
|
||||||
|
if err:
|
||||||
|
if combined:
|
||||||
|
combined += "\n"
|
||||||
|
combined += f"[STDERR]\n{err}"
|
||||||
|
|
||||||
|
if not combined:
|
||||||
|
combined = f"(دستور با کد {exit_code} بدون خروجی اجرا شد)"
|
||||||
|
|
||||||
|
# Limit output size to prevent overflow
|
||||||
|
if len(combined) > 3500:
|
||||||
|
combined = combined[:3500] + "\n... (خروجی طولانی کوتاه شد)"
|
||||||
|
|
||||||
|
return (exit_code == 0), combined, exit_code
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSH command execution failed: {e}")
|
||||||
|
return False, f"خطا در اجرای فرمان SSH: {e}", -1
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
success, output, code = await asyncio.to_thread(_run_exec)
|
||||||
|
dur = (time.time() - start_t) * 1000
|
||||||
|
|
||||||
|
# Audit log record
|
||||||
|
remote_audit.log_operation(
|
||||||
|
user_id=user_id,
|
||||||
|
project_name=project_name,
|
||||||
|
service_type="ssh",
|
||||||
|
action_type="exec",
|
||||||
|
requester=requester,
|
||||||
|
status="success" if success else "failed",
|
||||||
|
details=f"cmd: {command[:200]}",
|
||||||
|
result_summary=f"exit_code={code}, output={output[:100]}",
|
||||||
|
duration_ms=dur,
|
||||||
|
)
|
||||||
|
|
||||||
|
return success, output, code
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton instance
|
||||||
|
ssh_manager = SSHManager()
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -122,8 +123,23 @@ def parse_agy_usage_output(usage_raw: str, credits_raw: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def fetch_and_render_usage_report(is_fa: bool = True) -> str:
|
async def fetch_and_render_usage_report(is_fa: bool = True, user_id: Optional[int] = None) -> str:
|
||||||
"""Executes agy /usage and /credits and formats with visual progress bars."""
|
"""Executes agy /usage and /credits and formats with visual progress bars."""
|
||||||
|
from auth_manager import auth_manager
|
||||||
|
if user_id and not auth_manager.has_custom_account(user_id):
|
||||||
|
if is_fa:
|
||||||
|
return (
|
||||||
|
"⚠️ <b>حساب کاربری هوش مصنوعی متصل نیست!</b>\n\n"
|
||||||
|
"در این ربات حسابهای اشتراکی غیرفعال هستند و هر کاربر باید حساب گوگل اختصاصی خود را متصل کند.\n\n"
|
||||||
|
"👉 برای ورود و استعلام سهمیه اختصاصی، لطفاً دستور <code>/login</code> را ارسال کنید یا از منوی <code>/account</code> وارد شوید."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
"⚠️ <b>AGY Account Not Connected!</b>\n\n"
|
||||||
|
"Shared accounts are disabled. Please log in with your own Google account using <code>/login</code> or <code>/account</code>."
|
||||||
|
)
|
||||||
|
|
||||||
|
env = auth_manager.get_user_env(user_id) if user_id else os.environ.copy()
|
||||||
usage_raw = ""
|
usage_raw = ""
|
||||||
credits_raw = ""
|
credits_raw = ""
|
||||||
try:
|
try:
|
||||||
@@ -131,6 +147,7 @@ async def fetch_and_render_usage_report(is_fa: bool = True) -> str:
|
|||||||
"agy", "--print", "/usage",
|
"agy", "--print", "/usage",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
out1, _ = await proc1.communicate()
|
out1, _ = await proc1.communicate()
|
||||||
usage_raw = out1.decode("utf-8", errors="replace").strip()
|
usage_raw = out1.decode("utf-8", errors="replace").strip()
|
||||||
@@ -139,6 +156,7 @@ async def fetch_and_render_usage_report(is_fa: bool = True) -> str:
|
|||||||
"agy", "--print", "/credits",
|
"agy", "--print", "/credits",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
out2, _ = await proc2.communicate()
|
out2, _ = await proc2.communicate()
|
||||||
credits_raw = out2.decode("utf-8", errors="replace").strip()
|
credits_raw = out2.decode("utf-8", errors="replace").strip()
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
Reference in New Issue
Block a user