From 4479575894027123f82484ae9571e972406132ff Mon Sep 17 00:00:00 2001
From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com>
Date: Wed, 4 May 2022 23:24:10 +1000
Subject: [PATCH] -
---
.github/workflows/go.yml | 25 +
.github/workflows/release.yml | 42 ++
.gitignore | 15 +
LICENCE | 674 ++++++++++++++++++++++++++
Makefile | 32 ++
README.md | 29 ++
go.mod | 52 ++
go.sum | 202 ++++++++
internal/mtp/authflow/auth_term.go | 197 ++++++++
internal/mtp/authflow/util_unix.go | 9 +
internal/mtp/authflow/util_windows.go | 9 +
internal/mtp/bg/bg.go | 82 ++++
internal/mtp/creds_storage.go | 72 +++
internal/mtp/creds_storage_test.go | 59 +++
internal/mtp/dialogs.go | 110 +++++
internal/mtp/dialogs_filters.go | 25 +
internal/mtp/messages.go | 134 +++++
internal/mtp/messages_test.go | 87 ++++
internal/mtp/mtp.go | 220 +++++++++
internal/mtp/peer_storage.go | 129 +++++
internal/secure/int.go | 44 ++
internal/secure/int_test.go | 45 ++
internal/secure/secure.go | 343 +++++++++++++
internal/secure/secure_test.go | 390 +++++++++++++++
internal/secure/string.go | 39 ++
internal/secure/string_test.go | 108 +++++
internal/sys/sys.go | 35 ++
internal/sys/sys_test.go | 36 ++
internal/tui/app.go | 137 ++++++
internal/tui/chatlist.go | 136 ++++++
internal/tui/confirm.go | 66 +++
internal/tui/find.go | 51 ++
internal/tui/fsm.go | 145 ++++++
internal/tui/nothing.go | 11 +
main.go | 246 ++++++++++
35 files changed, 4036 insertions(+)
create mode 100644 .github/workflows/go.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 .gitignore
create mode 100644 LICENCE
create mode 100644 Makefile
create mode 100644 README.md
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 internal/mtp/authflow/auth_term.go
create mode 100644 internal/mtp/authflow/util_unix.go
create mode 100644 internal/mtp/authflow/util_windows.go
create mode 100644 internal/mtp/bg/bg.go
create mode 100644 internal/mtp/creds_storage.go
create mode 100644 internal/mtp/creds_storage_test.go
create mode 100644 internal/mtp/dialogs.go
create mode 100644 internal/mtp/dialogs_filters.go
create mode 100644 internal/mtp/messages.go
create mode 100644 internal/mtp/messages_test.go
create mode 100644 internal/mtp/mtp.go
create mode 100644 internal/mtp/peer_storage.go
create mode 100644 internal/secure/int.go
create mode 100644 internal/secure/int_test.go
create mode 100644 internal/secure/secure.go
create mode 100644 internal/secure/secure_test.go
create mode 100644 internal/secure/string.go
create mode 100644 internal/secure/string_test.go
create mode 100644 internal/sys/sys.go
create mode 100644 internal/sys/sys_test.go
create mode 100644 internal/tui/app.go
create mode 100644 internal/tui/chatlist.go
create mode 100644 internal/tui/confirm.go
create mode 100644 internal/tui/find.go
create mode 100644 internal/tui/fsm.go
create mode 100644 internal/tui/nothing.go
create mode 100644 main.go
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
new file mode 100644
index 0000000..53daa00
--- /dev/null
+++ b/.github/workflows/go.yml
@@ -0,0 +1,25 @@
+name: Go
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Go
+ uses: actions/setup-go@v3
+ with:
+ go-version: 1.18
+
+ - name: Build
+ run: go build -v ./...
+
+ - name: Test
+ run: go test -v ./...
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..2004fec
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,42 @@
+name: Release Go Binaries
+
+on:
+ release:
+ types: [created]
+ workflow_dispatch:
+
+env:
+ CMD_PATH: ./
+
+
+jobs:
+ releases-matrix:
+ name: Release Matrix
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ goos: [linux, windows, darwin]
+ goarch: ["386", amd64]
+ exclude:
+ - goarch: "386"
+ goos: darwin
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Set APP_VERSION env
+ run: echo APP_VERSION=$(echo ${GITHUB_REF} | rev | cut -d'/' -f 1 | rev ) >> ${GITHUB_ENV}
+ - name: Set BUILD_TIME env
+ run: echo BUILD_TIME=$(date) >> ${GITHUB_ENV}
+ - name: Environment Printer
+ uses: managedkaos/print-env@v1.0
+
+ - uses: wangyoucao577/go-release-action@v1.28
+ with:
+ github_token: ${{ secrets.ACTIONS_TOKEN }}
+ goos: ${{ matrix.goos }}
+ goarch: ${{ matrix.goarch }}
+ goversion: "1.18"
+ project_path: "${{ env.CMD_PATH }}"
+ build_flags: -v
+ ldflags: -X "main.version=${{ env.APP_VERSION }}" -X "main.builtOn=${{ env.BUILD_TIME }}" -X main.gitCommit=${{ github.sha }} -X main.gitRef=${{ github.ref }}
+ extra_files: LICENCE README.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..66fd13c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
diff --git a/LICENCE b/LICENCE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENCE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..28c14a5
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,32 @@
+SHELL=/bin/sh
+
+OUTPUT=tgmsgdel
+
+.PHONY: clean cleanall cleanfiles debug run
+
+
+export CGO_LDFLAGS="-L/usr/local/opt/openssl/lib"
+
+$(OUTPUT): main.go
+ go build -o $@
+
+debug:
+ dlv debug .
+
+run:
+ go run .
+
+clean:
+ -rm $(OUTPUT)
+
+test:
+ go test ./... -race -cover
+
+fuzz:
+ go test -fuzz=Fuzz -fuzztime 30s ./internal/secure
+ go test -fuzz=Fuzz -fuzztime 30s ./internal/mtp
+
+cleanfiles:
+ -rm -rf tdlib-db tdlib-files
+
+cleanall: clean cleanfiles
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..00efaf1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,29 @@
+# Wipe My Chat
+Delete all your messages in public and private chats.
+
+---
+> _In loving memory of V. Gorban, 1967-2022._
+---
+
+## Usage
+
+1. Download the release from the [Releases page][1];
+2. Unpack;
+3. Run.
+
+You will need:
+- Telegram API ID
+- Telegram API HASH
+
+Don't worry, the program provides easy to follow instructions on how to get
+those.
+
+To authenticate, you will use your Telegram Account phone number and the code,
+that will be sent to you in-app or text message (SMS).
+
+
+## Licence
+GNU Public Licence 3.0, see [LICENCE][2]
+
+[1]: https://github.com/rusq/wipemychat/releases
+[2]: LICENCE
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..3cc68ba
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,52 @@
+module github.com/rusq/wipemychat
+
+go 1.18
+
+require (
+ github.com/bluele/gcache v0.0.2
+ github.com/fatih/color v1.13.0
+ github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1
+ github.com/gotd/contrib v0.12.0
+ github.com/gotd/td v0.57.0
+ github.com/joho/godotenv v1.4.0
+ github.com/looplab/fsm v0.3.0
+ github.com/mattn/go-colorable v0.1.12
+ github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8
+ github.com/rusq/dlog v1.3.3
+ github.com/rusq/osenv/v2 v2.0.1
+ github.com/rusq/tracer v1.0.0
+ github.com/schollz/progressbar/v3 v3.8.6
+ github.com/stretchr/testify v1.7.1
+ go.uber.org/zap v1.21.0
+ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
+)
+
+require (
+ github.com/cenkalti/backoff/v4 v4.1.3 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/gdamore/encoding v1.0.0 // indirect
+ github.com/go-faster/errors v0.5.0 // indirect
+ github.com/go-faster/jx v0.33.0 // indirect
+ github.com/go-faster/xor v0.3.0 // indirect
+ github.com/gotd/ige v0.2.2 // indirect
+ github.com/gotd/neo v0.1.5 // indirect
+ github.com/klauspost/compress v1.15.1 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.14 // indirect
+ github.com/mattn/go-runewidth v0.0.13 // indirect
+ github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/segmentio/asm v1.1.3 // indirect
+ go.uber.org/atomic v1.9.0 // indirect
+ go.uber.org/multierr v1.8.0 // indirect
+ golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 // indirect
+ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
+ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
+ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect
+ golang.org/x/text v0.3.7 // indirect
+ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
+ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
+ nhooyr.io/websocket v1.8.7 // indirect
+ rsc.io/qr v0.2.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..8068d82
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,202 @@
+github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
+github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
+github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
+github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4=
+github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
+github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
+github.com/cockroachdb/errors v1.8.1 h1:A5+txlVZfOqFBDa4mGz2bUWSp0aHElvHX2bKkdbQu+Y=
+github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f h1:o/kfcElHqOiXqcou5a3rIlMc7oJbMQkeLk0VQJ7zgqY=
+github.com/cockroachdb/pebble v0.0.0-20210423210359-b62f76615457 h1:HQPE1RrXA1A8MPZrIv90t1KyYYEf07uYA+E7cCjgdPs=
+github.com/cockroachdb/redact v1.0.8 h1:8QG/764wK+vmEYoOlfobpe12EQcS81ukx/a4hdVMxNw=
+github.com/cockroachdb/sentry-go v0.6.1-cockroachdb.2 h1:IKgmqgMQlVJIZj19CdocBeSfSaiCbEBZGKODaixqtHM=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
+github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 h1:QqwPZCwh/k1uYqq6uXSb9TRDhTkfQbO80v8zhnIe5zM=
+github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
+github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
+github.com/go-faster/errors v0.5.0 h1:hS/zHFJ2Vb14jcupq5J9tk05XW+PFTmySOkDRByHBo4=
+github.com/go-faster/errors v0.5.0/go.mod h1:/9SNBcg2ESJTYztBFEiM5Np6ns85BtPNMJd8lFTiFwk=
+github.com/go-faster/jx v0.33.0 h1:YTiLVjpeVr7Zv66BivgBGa3f33a7cup8uFleiBg82qY=
+github.com/go-faster/jx v0.33.0/go.mod h1:Ya1ynk3qKb3R7bJwORf0QggETqlOvLO1Owjr3R4crqk=
+github.com/go-faster/xor v0.3.0 h1:tc0bdVe31Wj999e5rEj7K3DhHyQNp2VydYyLFj3YSN8=
+github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
+github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
+github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
+github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
+github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
+github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
+github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
+github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/gotd/contrib v0.12.0 h1:9J5/ZCB8edJP4KGUejjKfoMGucMCoQFBdQqAUU0qz/c=
+github.com/gotd/contrib v0.12.0/go.mod h1:rIsUoHWamqAc9ULZa4VY/IKHHSXXWMmjuGJnUld1InI=
+github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
+github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
+github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
+github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
+github.com/gotd/td v0.57.0 h1:33DHkkfoJSeT94PM3bfPI3gr2D4Nv/KoQwIZBFOG9uc=
+github.com/gotd/td v0.57.0/go.mod h1:CPyg0p4VJM8GeVHD2zBX905G7auYfNZeHdR+anvS4mQ=
+github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
+github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ=
+github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
+github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/klauspost/compress v1.15.1 h1:y9FcTHGyrebwfP0ZZqFiaxTaiDnUrGkJkI+f583BL1A=
+github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/looplab/fsm v0.3.0 h1:kIgNS3Yyud1tyxhG8kDqh853B7QqwnlWdgL3TD2s3Sw=
+github.com/looplab/fsm v0.3.0/go.mod h1:PmD3fFvQEIsjMEfvZdrCDZ6y8VwKTwWNjlpEr6IKPO4=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
+github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 h1:xe+mmCnDN82KhC010l3NfYlA8ZbOuzbXAzSYBa6wbMc=
+github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rusq/dlog v1.3.3 h1:Q9fZW1H/YEnlDg3Ph1k/BRSBfi/q5ezI+8Metws9tTI=
+github.com/rusq/dlog v1.3.3/go.mod h1:kjZAEvBu7m3+mnJQKoIeLul1YB3kJq/6lZBdDTZmpzA=
+github.com/rusq/osenv/v2 v2.0.1 h1:1LtNt8VNV/W86wb38Hyu5W3Rwqt/F1JNRGE+8GRu09o=
+github.com/rusq/osenv/v2 v2.0.1/go.mod h1:+wJBSisjNZpfoD961JzqjaM+PtaqSusO3b4oVJi7TFY=
+github.com/rusq/tracer v1.0.0 h1:AoagAH2LcN1jtxhf0H0/gdP+TezkaGK0NA1OTYq8C10=
+github.com/rusq/tracer v1.0.0/go.mod h1:Rqu48C3/K8bA5NPmF20Hft73v431MQIdM+Co+113pME=
+github.com/schollz/progressbar/v3 v3.8.6 h1:QruMUdzZ1TbEP++S1m73OqRJk20ON11m6Wqv4EoGg8c=
+github.com/schollz/progressbar/v3 v3.8.6/go.mod h1:W5IEwbJecncFGBvuEh4A7HT1nZZ6WNIL2i3qbnI0WKY=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
+github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
+github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
+github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
+go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
+go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
+go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
+go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 h1:71vQrMauZZhcTVK6KdYM+rklehEEwb3E+ZhaE5jrPrE=
+golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/exp v0.0.0-20200513190911-00229845015e h1:rMqLP+9XLy+LdbCXHjJHAmTfXCr93W7oruWA6Hq1Alc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
+nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
+rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
+rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
diff --git a/internal/mtp/authflow/auth_term.go b/internal/mtp/authflow/auth_term.go
new file mode 100644
index 0000000..e876d17
--- /dev/null
+++ b/internal/mtp/authflow/auth_term.go
@@ -0,0 +1,197 @@
+package authflow
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/fatih/color"
+ "github.com/gotd/td/telegram/auth"
+ "github.com/gotd/td/tg"
+ "golang.org/x/term"
+)
+
+type FullAuthFlow interface {
+ auth.UserAuthenticator
+
+ GetAPICredentials(ctx context.Context) (int, string, error)
+}
+
+var (
+ blink = color.New(color.BlinkSlow)
+ italic = color.New(color.Italic)
+ param = color.New(color.Italic, color.FgBlue, color.BgHiWhite)
+ warn = color.New(color.FgHiRed)
+ underline = color.New(color.Underline)
+
+ line = strings.Repeat("-=", 40)
+)
+
+// noSignUp can be embedded to prevent signing up.
+type noSignUp struct{}
+
+func (c noSignUp) SignUp(ctx context.Context) (auth.UserInfo, error) {
+ return auth.UserInfo{}, errors.New("not implemented")
+}
+
+func (c noSignUp) AcceptTermsOfService(ctx context.Context, tos tg.HelpTermsOfService) error {
+ return &auth.SignUpRequired{TermsOfService: tos}
+}
+
+// TermAuth implements authentication via terminal.
+type TermAuth struct {
+ noSignUp
+
+ phone string
+}
+
+func NewTermAuth(phone string) TermAuth {
+ return TermAuth{phone: phone}
+}
+
+func (a TermAuth) Phone(_ context.Context) (string, error) {
+ clrscr(os.Stdout)
+ if a.phone != "" {
+ return a.phone, nil
+ }
+ fmt.Printf("Connected, please login to Telegram.\n\n")
+ fmt.Print("Enter phone: ")
+ return readln(os.Stdin)
+}
+
+func (a TermAuth) Password(ctx context.Context) (string, error) {
+ defer fmt.Println()
+ fmt.Print("Enter 2FA password (won't be shown): ")
+ return readpass(ctx)
+}
+
+func getCodeSpecifics(code *tg.AuthSentCode) (string, int) {
+ digits := func(where string, n int) string {
+ return fmt.Sprintf("The code %s.\nEnter exactly %d digits.", where, n)
+ }
+
+ switch val := code.Type.(type) {
+ case *tg.AuthSentCodeTypeApp:
+ return digits("was sent through the telegram app", val.GetLength()), val.GetLength()
+ case *tg.AuthSentCodeTypeSMS:
+ return digits("will be sent via a text message (SMS)", val.GetLength()), val.GetLength()
+ case *tg.AuthSentCodeTypeCall:
+ return digits("will be sent via a phone call, and a synthesized voice will tell you what to input", val.GetLength()), val.GetLength()
+ case *tg.AuthSentCodeTypeFlashCall:
+ return fmt.Sprintf("The code will be sent via a flash phone call, that will be closed immediately.\nThe phone code will then be the phone number itself, just make sure that the\nphone number matches the specified pattern: %q (%d characters)", val.GetPattern(), len(val.GetPattern())), len(val.GetPattern())
+ case *tg.AuthSentCodeTypeMissedCall:
+
+ return fmt.Sprintf("The code will be sent via a flash phone call, that will be closed immediately.\nThe last digits of the phone number that calls are the code that must be entered.\nThe phone call prefix will be: %s and the length of the code is %d", val.GetPrefix(), val.GetLength()), val.GetLength()
+ default:
+ return "UNSUPPORTED AUTH TYPE", 0
+ }
+}
+
+func getCodeTimeout(code *tg.AuthSentCode) (string, time.Duration) {
+ timeout, ok := code.GetTimeout()
+ if !ok {
+ return "", 30 * time.Minute
+ }
+ ret := time.Duration(timeout) * time.Second
+ return fmt.Sprintf("(enter code within %s)", ret), ret
+}
+
+func (a TermAuth) Code(_ context.Context, code *tg.AuthSentCode) (string, error) {
+ codeHelp, length := getCodeSpecifics(code)
+ timeoutHelp, timeoutIn := getCodeTimeout(code)
+ timeout := time.Now().Add(timeoutIn)
+
+ var input string
+ var err error
+ for {
+ if time.Now().After(timeout) {
+ return "", errors.New("operation timed out")
+ }
+ fmt.Printf("(i) TIP: %s\nEnter code%s: ", codeHelp, timeoutHelp)
+ input, err = readln(os.Stdin)
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ return "", errors.New("login aborted")
+ }
+ }
+ if len(input) == length || length == 0 {
+ break
+ }
+ fmt.Println("*** Invalid code, try again [Press Ctrl+C to abort] ***")
+ }
+ return input, nil
+}
+
+func (a TermAuth) GetAPICredentials(ctx context.Context) (int, string, error) {
+ instructions()
+ var id int
+ for {
+ fmt.Printf("Enter App '%s': ", param.Sprint(" api_id "))
+ sID, err := readln(os.Stdin)
+ if err != nil {
+ return 0, "", err
+ }
+ id, err = strconv.Atoi(sID)
+ if err == nil {
+ break
+ }
+ fmt.Println("*** Input error: api_id should be an integer")
+ }
+ fmt.Printf("Enter App '%s' (won't be shown): ", param.Sprint(" api_hash "))
+ hash, err := readpass(ctx)
+ fmt.Println()
+ if err != nil {
+ return 0, "", err
+ }
+ return id, hash, nil
+}
+
+func instructions() {
+
+ fmt.Println(line)
+ fmt.Printf("To get the API ID and API Hash, follow the instructions:\n\n")
+ fmt.Printf("\t1. Login to telegram \"API Development tools\":\n")
+ fmt.Printf("\t\t%s %s %s\n", blink.Sprint("->"), italic.Sprint("https://my.telegram.org/apps"), blink.Sprint("<-"))
+ fmt.Printf("\t2. Fill in the form: %s, %s and %s can be any values\n\t you like;\n"+
+ "\t3. Choose \"%s\" platform\n"+
+ "\t4. Click button.\n\n",
+ underline.Sprint("App title"), underline.Sprint("Short Name"), underline.Sprint("URL"),
+ underline.Sprint("Desktop"))
+ fmt.Printf("You will see the App '%s' and App '%s' values that you will need to\n"+
+ "enter shortly. This application will encrypt and save the credentials on your\ndevice. You can delete them any time starting with -reset flag.\n\n",
+ param.Sprint(" api_id "), param.Sprint(" api_hash "))
+ warn.Printf("VERY IMPORTANT: This is the key to your account, keep it secret, never share\n" +
+ "it with anyone, never publish it online.\n")
+ fmt.Println(line)
+ fmt.Println()
+}
+
+func readln(r io.Reader) (string, error) {
+ line, err := bufio.NewReader(r).ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(line), nil
+}
+
+func readpass(_ context.Context) (string, error) {
+ stdin := int(os.Stdin.Fd())
+
+ oldState, err := term.MakeRaw(stdin)
+ if err != nil {
+ return "", err
+ }
+ defer term.Restore(stdin, oldState)
+
+ bytePwd, err := term.ReadPassword(stdin)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(bytePwd)), nil
+}
diff --git a/internal/mtp/authflow/util_unix.go b/internal/mtp/authflow/util_unix.go
new file mode 100644
index 0000000..6cc15e0
--- /dev/null
+++ b/internal/mtp/authflow/util_unix.go
@@ -0,0 +1,9 @@
+//go:build !windows
+
+package authflow
+
+import "io"
+
+func clrscr(w io.Writer) {
+ w.Write([]byte("\033c"))
+}
diff --git a/internal/mtp/authflow/util_windows.go b/internal/mtp/authflow/util_windows.go
new file mode 100644
index 0000000..9ea09c3
--- /dev/null
+++ b/internal/mtp/authflow/util_windows.go
@@ -0,0 +1,9 @@
+// go:build windows
+
+package authflow
+
+import "io"
+
+func clrscr(w io.Writer) {
+ return
+}
diff --git a/internal/mtp/bg/bg.go b/internal/mtp/bg/bg.go
new file mode 100644
index 0000000..123d7ae
--- /dev/null
+++ b/internal/mtp/bg/bg.go
@@ -0,0 +1,82 @@
+// Package bg implements wrapper for running client in background.
+//
+// TODO: Once https://github.com/gotd/contrib/pull/216 is merged can be removed.
+package bg
+
+import (
+ "context"
+ "errors"
+)
+
+// Client abstracts telegram client.
+type Client interface {
+ Run(ctx context.Context, f func(ctx context.Context) error) error
+}
+
+// StopFunc closes Client and waits until Run returns.
+type StopFunc func() error
+
+type connectOptions struct {
+ ctx context.Context
+}
+
+// Option for Connect.
+type Option interface {
+ apply(o *connectOptions)
+}
+
+type fnOption func(o *connectOptions)
+
+func (f fnOption) apply(o *connectOptions) {
+ f(o)
+}
+
+// WithContext sets base context for client.
+func WithContext(ctx context.Context) Option {
+ return fnOption(func(o *connectOptions) {
+ o.ctx = ctx
+ })
+}
+
+// Connect blocks until client is connected, calling Run internally in
+// background.
+func Connect(client Client, options ...Option) (StopFunc, error) {
+ opt := &connectOptions{
+ ctx: context.Background(),
+ }
+ for _, o := range options {
+ o.apply(opt)
+ }
+
+ ctx, cancel := context.WithCancel(opt.ctx)
+
+ initDone := make(chan struct{})
+ errC := make(chan error, 1)
+ go func() {
+ defer close(errC)
+ errC <- client.Run(ctx, func(ctx context.Context) error {
+ close(initDone)
+ <-ctx.Done()
+ if errors.Is(ctx.Err(), context.Canceled) {
+ return nil
+ }
+ return ctx.Err()
+ })
+ }()
+
+ select {
+ case <-ctx.Done(): // context cancelled
+ cancel()
+ return func() error { return nil }, ctx.Err()
+ case err := <-errC: // startup timeout
+ cancel()
+ return func() error { return nil }, err
+ case <-initDone: // init done
+ }
+
+ stopFn := func() error {
+ cancel()
+ return <-errC
+ }
+ return stopFn, nil
+}
diff --git a/internal/mtp/creds_storage.go b/internal/mtp/creds_storage.go
new file mode 100644
index 0000000..236b040
--- /dev/null
+++ b/internal/mtp/creds_storage.go
@@ -0,0 +1,72 @@
+package mtp
+
+import (
+ "encoding/json"
+ "io"
+ "os"
+
+ "github.com/rusq/wipemychat/internal/secure"
+)
+
+type credsStorage struct {
+ filename string
+ passphrase []byte
+}
+
+// creds is the structure of data in the storage.
+type creds struct {
+ ApiID secure.Int `json:"api_id,omitempty"`
+ ApiHash secure.String `json:"api_hash,omitempty"`
+}
+
+func (cs credsStorage) IsAvailable() bool {
+ return cs.filename != "" && len(cs.passphrase) > 0
+}
+
+func (cs credsStorage) Save(apiID int, apiHash string) error {
+ if err := secure.SetPassphrase(cs.passphrase); err != nil {
+ return err
+ }
+ f, err := os.Create(cs.filename)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ return cs.write(f, apiID, apiHash)
+}
+
+func (cs credsStorage) write(f io.Writer, apiID int, apiHash string) error {
+ creds := creds{
+ ApiID: secure.Int(apiID),
+ ApiHash: secure.String(apiHash),
+ }
+
+ enc := json.NewEncoder(f)
+ if err := enc.Encode(creds); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (cs credsStorage) Load() (int, string, error) {
+ if err := secure.SetPassphrase(cs.passphrase); err != nil {
+ return 0, "", err
+ }
+ f, err := os.Open(cs.filename)
+ if err != nil {
+ return 0, "", err
+ }
+ defer f.Close()
+
+ return cs.read(f)
+}
+
+func (cs credsStorage) read(r io.Reader) (int, string, error) {
+ var creds creds
+ dec := json.NewDecoder(r)
+ if err := dec.Decode(&creds); err != nil {
+ return 0, "", err
+ }
+ return int(creds.ApiID), creds.ApiHash.String(), nil
+}
diff --git a/internal/mtp/creds_storage_test.go b/internal/mtp/creds_storage_test.go
new file mode 100644
index 0000000..833a9c9
--- /dev/null
+++ b/internal/mtp/creds_storage_test.go
@@ -0,0 +1,59 @@
+package mtp
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/rusq/wipemychat/internal/secure"
+)
+
+func init() {
+ mac := []byte{0xde, 0xfe, 0xc8, 0xed, 0xba, 0xbe}
+ if err := secure.SetPassphrase(mac); err != nil {
+ panic(err)
+ }
+}
+
+func Test_encryptDecrypt(t *testing.T) {
+ var (
+ ApiID = 12345
+ ApiHash = "very secure"
+ )
+ var buf bytes.Buffer
+ cs := credsStorage{}
+ err := cs.write(&buf, ApiID, ApiHash)
+ assert.NoError(t, err)
+
+ gotID, gotHash, gotErr := cs.read(&buf)
+ assert.NoError(t, gotErr)
+ assert.Equal(t, ApiID, gotID)
+ assert.Equal(t, ApiHash, gotHash)
+
+}
+
+func FuzzWriteRead(f *testing.F) {
+ type testcase struct {
+ id int
+ hash string
+ }
+ var testcases = []testcase{{12345, "very secure"}, {0, "12345"}, {42, ""}, {-100, "blah"}}
+ for _, tc := range testcases {
+ f.Add(tc.id, tc.hash)
+ }
+ cs := credsStorage{}
+ f.Fuzz(func(t *testing.T, id int, hash string) {
+ var buf bytes.Buffer
+ err := cs.write(&buf, id, hash)
+ if err != nil {
+ return
+ }
+ gotID, gotHash, gotErr := cs.read(&buf)
+ if gotErr != nil {
+ return
+ }
+ assert.Equal(t, id, gotID)
+ assert.Equal(t, hash, gotHash)
+ })
+}
diff --git a/internal/mtp/dialogs.go b/internal/mtp/dialogs.go
new file mode 100644
index 0000000..ab30014
--- /dev/null
+++ b/internal/mtp/dialogs.go
@@ -0,0 +1,110 @@
+package mtp
+
+import (
+ "context"
+ "errors"
+ "runtime/trace"
+
+ "github.com/gotd/contrib/storage"
+
+ "github.com/gotd/td/telegram/query/dialogs"
+ "github.com/gotd/td/tg"
+)
+
+// GetChats retrieves the account chats.
+func (c *Client) GetChats(ctx context.Context) ([]Entity, error) {
+ return c.GetEntities(ctx, FilterChat())
+}
+
+// GetChannels retrieves the account channels.
+func (c *Client) GetChannels(ctx context.Context) ([]Entity, error) {
+ return c.GetEntities(ctx, FilterChannel())
+}
+
+// GetEntities ensures that storage is populated, then iterates through storage
+// peers calling filterFn for each peer. The filterFn should return Entity and
+// true, if the peer satisfies the criteria, or nil and false, otherwise.
+func (c *Client) GetEntities(ctx context.Context, filterFn FilterFunc) ([]Entity, error) {
+ ctx, task := trace.NewTask(ctx, "GetEntities")
+ defer task.End()
+
+ if err := c.ensureStoragePopulated(ctx); err != nil {
+ return nil, err
+ }
+
+ peerIter, err := c.storage.Iterate(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer peerIter.Close()
+
+ var ee []Entity
+
+ for peerIter.Next(ctx) {
+ ent, ok := filterFn(peerIter.Value())
+ if !ok {
+ continue
+ }
+ ee = append(ee, ent)
+ }
+ if err := peerIter.Err(); err != nil {
+ return nil, err
+ }
+ return ee, nil
+}
+
+// ensureStoragePopulated ensures that the peer storage has been populated within
+// defCacheEvict duration.
+func (c *Client) ensureStoragePopulated(ctx context.Context) error {
+ if cached, err := c.cache.Get(cacheDlgStorage); err == nil && cached.(bool) {
+ trace.Log(ctx, "cache", "hit")
+ return nil
+ }
+ // populating the storage
+ trace.Log(ctx, "cache", "miss")
+
+ dlgIter := dialogs.NewQueryBuilder(c.cl.API()).
+ GetDialogs().
+ BatchSize(defBatchSize).
+ Iter()
+ if err := storage.CollectPeers(c.storage).Dialogs(ctx, dlgIter); err != nil {
+ return err
+ }
+ if err := c.cache.SetWithExpire(cacheDlgStorage, true, defCacheEvict); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// CreateChat creates a Chat (not a Mega- or Gigagroup).
+//
+// Example
+//
+// if err := cl.CreateChat(ctx, "mtproto-test",123455678, 312849128); err != nil {
+// return err
+// }
+func (c *Client) CreateChat(ctx context.Context, title string, userIDs ...int64) error {
+ if len(userIDs) == 0 {
+ return errors.New("at least one user is required")
+ }
+
+ var others = make([]tg.InputUserClass, len(userIDs))
+ for i := range userIDs {
+ others[i] = &tg.InputUser{UserID: userIDs[i]}
+ }
+
+ var users = append([]tg.InputUserClass{&tg.InputUserSelf{}}, others...)
+
+ var resp tg.Updates
+ if err := c.cl.Invoke(ctx,
+ &tg.MessagesCreateChatRequest{
+ Users: users,
+ Title: title,
+ },
+ &resp,
+ ); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/internal/mtp/dialogs_filters.go b/internal/mtp/dialogs_filters.go
new file mode 100644
index 0000000..a612773
--- /dev/null
+++ b/internal/mtp/dialogs_filters.go
@@ -0,0 +1,25 @@
+package mtp
+
+import "github.com/gotd/contrib/storage"
+
+type FilterFunc func(storage.Peer) (ent Entity, ok bool)
+
+func FilterChat() FilterFunc {
+ return func(peer storage.Peer) (Entity, bool) {
+ if peer.Chat != nil {
+ return peer.Chat, true
+ } else if peer.Channel != nil && !peer.Channel.Broadcast {
+ return peer.Channel, true
+ }
+ return nil, false
+ }
+}
+
+func FilterChannel() FilterFunc {
+ return func(peer storage.Peer) (Entity, bool) {
+ if peer.Channel != nil && peer.Channel.Broadcast {
+ return peer.Channel, true
+ }
+ return nil, false
+ }
+}
diff --git a/internal/mtp/messages.go b/internal/mtp/messages.go
new file mode 100644
index 0000000..332ab83
--- /dev/null
+++ b/internal/mtp/messages.go
@@ -0,0 +1,134 @@
+package mtp
+
+import (
+ "context"
+ "fmt"
+ "runtime/trace"
+
+ "github.com/gotd/td/telegram/message"
+ "github.com/gotd/td/telegram/query"
+ "github.com/gotd/td/telegram/query/messages"
+ "github.com/gotd/td/tg"
+)
+
+// SearchAllMyMessages returns the current authorized user messages from chat or
+// channel `dlg`. For each API call, the callback function will be invoked, if
+// not nil.
+func (c *Client) SearchAllMyMessages(ctx context.Context, dlg Entity, cb func(n int)) ([]messages.Elem, error) {
+ return c.SearchAllMessages(ctx, dlg, &tg.InputPeerSelf{}, cb)
+}
+
+// SearchAllMessages search messages in the chat or channel `dlg`. It finds ALL
+// messages from the person `who`. returns a slice of message.Elem. For each API
+// call, the callback function will be invoked, if not nil.
+func (c *Client) SearchAllMessages(ctx context.Context, dlg Entity, who tg.InputPeerClass, cb func(n int)) ([]messages.Elem, error) {
+ if cached, err := c.cache.Get(cacheKey(dlg.GetID())); err == nil {
+ msgs := cached.([]messages.Elem)
+ if cb != nil {
+ cb(len(msgs))
+ }
+ return msgs, nil
+ }
+
+ ip, err := asInputPeer(dlg)
+ if err != nil {
+ return nil, err
+ }
+
+ bld := query.Messages(c.cl.API()).
+ Search(ip).
+ BatchSize(defBatchSize).
+ FromID(who).
+ Filter(&tg.InputMessagesFilterEmpty{})
+ elems, err := collectMessages(ctx, bld, cb)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := c.cache.Set(cacheKey(dlg.GetID()), elems); err != nil {
+ return nil, err
+ }
+ return elems, err
+}
+
+func (c *Client) DeleteMessages(ctx context.Context, dlg Entity, messages []messages.Elem) (int, error) {
+ ctx, task := trace.NewTask(ctx, "DeleteMessages")
+ defer task.End()
+
+ ip, err := asInputPeer(dlg)
+ if err != nil {
+ trace.Log(ctx, "logic", err.Error())
+ return 0, err
+ }
+ ids := splitBy(defBatchSize, messages, func(i int) int { return messages[i].Msg.GetID() })
+ trace.Logf(ctx, "logic", "split chunks: %d", len(ids))
+
+ // clearing cache.
+ if c.cache.Remove(cacheKey(dlg.GetID())) {
+ trace.Log(ctx, "logic", "cache cleared")
+ }
+
+ total := 0
+ for _, chunk := range ids {
+ resp, err := message.NewSender(c.cl.API()).To(ip).Revoke().Messages(ctx, chunk...)
+ if err != nil {
+ trace.Logf(ctx, "api", "revoke error: %s", err)
+ return 0, fmt.Errorf("failed to delete: %w", err)
+ }
+ total += resp.GetPtsCount()
+ }
+ trace.Log(ctx, "logic", "ok")
+ return total, nil
+}
+
+func asInputPeer(ent Entity) (tg.InputPeerClass, error) {
+ switch peer := ent.(type) {
+ case *tg.Chat:
+ return peer.AsInputPeer(), nil
+ case *tg.Channel:
+ return peer.AsInputPeer(), nil
+ default:
+ return nil, fmt.Errorf("unsupported input peer type: %T", peer)
+ }
+ // unreachable
+}
+
+// splitBy splits the chunk input of M items to X chunks of `n` items.
+// For each element of input, the fn is called, that should return
+// the value.
+func splitBy[T, S any](n int, input []S, fn func(i int) T) [][]T {
+ var out [][]T = make([][]T, 0, len(input)/n)
+ var chunk []T
+ for i := range input {
+ if i > 0 && i%n == 0 {
+ out = append(out, chunk)
+ chunk = make([]T, 0, n)
+ }
+ chunk = append(chunk, fn(i))
+ }
+ if len(chunk) > 0 {
+ out = append(out, chunk)
+ }
+ return out
+}
+
+// collectMessages is the copy/pasta from the td/telegram/message package with added
+// optional callback function. It creates iterator and collects all elements to
+// slice, calling callback function for each iteration, if it's not nil.
+func collectMessages(ctx context.Context, b *messages.SearchQueryBuilder, cb func(n int)) ([]messages.Elem, error) {
+ iter := b.Iter()
+ c, err := iter.Total(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("get total: %w", err)
+ }
+
+ r := make([]messages.Elem, 0, c)
+ for iter.Next(ctx) {
+ r = append(r, iter.Value())
+ if cb != nil {
+ cb(1)
+ }
+ }
+
+ return r, iter.Err()
+}
diff --git a/internal/mtp/messages_test.go b/internal/mtp/messages_test.go
new file mode 100644
index 0000000..a8332c7
--- /dev/null
+++ b/internal/mtp/messages_test.go
@@ -0,0 +1,87 @@
+package mtp
+
+import (
+ "reflect"
+ "testing"
+)
+
+func Test_splitBy(t *testing.T) {
+ var (
+ testInputEven = []int{1, 2, 3, 4, 5}
+ testInputOdd = []int{1, 2, 3, 4, 5, 6}
+ testInputSngl = []int{42}
+ )
+ type args struct {
+ n int
+ input []int
+ fn func(i int) int8
+ }
+ tests := []struct {
+ name string
+ args args
+ want [][]int8
+ }{
+ {
+ "splits as expected (even)",
+ args{
+ n: 2,
+ input: testInputEven,
+ fn: func(i int) int8 {
+ return int8(testInputEven[i])
+ },
+ },
+ [][]int8{{1, 2}, {3, 4}, {5}},
+ },
+ {
+ "splits as expected (odd)",
+ args{
+ n: 2,
+ input: testInputOdd,
+ fn: func(i int) int8 {
+ return int8(testInputOdd[i])
+ },
+ },
+ [][]int8{{1, 2}, {3, 4}, {5, 6}},
+ },
+ {
+ "splits as expected (odd)",
+ args{
+ n: 3,
+ input: testInputOdd,
+ fn: func(i int) int8 {
+ return int8(testInputOdd[i])
+ },
+ },
+ [][]int8{{1, 2, 3}, {4, 5, 6}},
+ },
+ {
+ "splits as expected (empty)",
+ args{
+ n: 2,
+ input: []int{},
+ fn: func(i int) int8 {
+ return 0
+ },
+ },
+ [][]int8{},
+ },
+ {
+ "splits as expected (one)",
+ args{
+ n: 2,
+ input: testInputSngl,
+ fn: func(i int) int8 {
+ return int8(testInputSngl[i])
+ },
+ },
+ [][]int8{{42}},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := splitBy(tt.args.n, tt.args.input, tt.args.fn); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("splitBy() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/mtp/mtp.go b/internal/mtp/mtp.go
new file mode 100644
index 0000000..a30d6dc
--- /dev/null
+++ b/internal/mtp/mtp.go
@@ -0,0 +1,220 @@
+// Package mtp provides some functions for the gotd/td functions
+package mtp
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/bluele/gcache"
+ "github.com/gotd/contrib/middleware/floodwait"
+ "github.com/gotd/contrib/storage"
+ "github.com/gotd/td/session"
+ "github.com/gotd/td/tdp"
+ "github.com/gotd/td/telegram"
+ "github.com/gotd/td/telegram/auth"
+ "github.com/mattn/go-colorable"
+ "github.com/rusq/dlog"
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+
+ "github.com/rusq/wipemychat/internal/mtp/authflow"
+ "github.com/rusq/wipemychat/internal/mtp/bg"
+)
+
+const (
+ defBatchSize = 100
+ defCacheEvict = 10 * time.Minute
+ defCacheSz = 20
+)
+
+var (
+ // ErrAlreadyRunning is returned if the attempt is made to start the client,
+ // while there's another instance running asynchronously.
+ ErrAlreadyRunning = errors.New("already running asynchronously, stop the running instance first")
+)
+
+type Client struct {
+ cl *telegram.Client
+
+ cache gcache.Cache
+ storage storage.PeerStorage
+ creds credsStorage
+
+ waiter *floodwait.SimpleWaiter
+ waiterStop func()
+
+ stop bg.StopFunc
+
+ auth authflow.FullAuthFlow
+ sendcodeOpts auth.SendCodeOptions
+ telegramOpts telegram.Options
+}
+
+// Entity interface is the subset of functions that are commonly defined on most
+// entities in telegram lib. It can be a user, a chat or channel, or any other
+// telegram Entity.
+type Entity interface {
+ GetID() int64
+ GetTitle() string
+ TypeInfo() tdp.Type
+ Zero() bool
+}
+
+type cacheKey int64
+
+const (
+ cacheDlgStorage cacheKey = iota
+)
+
+type Option func(c *Client)
+
+func WithMTPOptions(opts telegram.Options) Option {
+ return func(c *Client) {
+ c.telegramOpts = opts
+ }
+}
+
+// WithStorage allows to specify custom session storage.
+func WithStorage(path string) Option {
+ return func(c *Client) {
+ c.telegramOpts.SessionStorage = &session.FileStorage{Path: path}
+ }
+}
+
+// WithPeerStorage allows to specify a custom storage for peer data.
+func WithPeerStorage(s storage.PeerStorage) Option {
+ return func(c *Client) {
+ if s == nil {
+ return
+ }
+ c.storage = s
+ }
+}
+
+// WithAuth allows to override the authorization flow
+func WithAuth(flow authflow.FullAuthFlow) Option {
+ return func(c *Client) {
+ c.auth = flow
+ }
+}
+
+func WithApiCredsFile(path string, passphrase []byte) Option {
+ return func(c *Client) {
+ c.creds = credsStorage{filename: path, passphrase: passphrase}
+ }
+}
+
+func WithDebug(enable bool) Option {
+ return func(c *Client) {
+ if !enable {
+ c.telegramOpts.Logger = nil
+ return
+ }
+ cfg := zap.NewDevelopmentEncoderConfig()
+ cfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
+ c.telegramOpts.Logger = zap.New(zapcore.NewCore(
+ zapcore.NewConsoleEncoder(cfg),
+ zapcore.AddSync(colorable.NewColorableStdout()),
+ zapcore.DebugLevel,
+ ))
+ }
+}
+
+func New(appID int, appHash string, opts ...Option) (*Client, error) {
+ // Client with the default parameters
+ var c = Client{
+ cache: gcache.New(defCacheSz).LFU().Expiration(defCacheEvict).Build(),
+ storage: NewMemStorage(),
+
+ auth: authflow.TermAuth{}, // default is the terminal authentication
+ waiter: floodwait.NewSimpleWaiter(),
+
+ telegramOpts: telegram.Options{},
+ }
+
+ for _, opt := range opts {
+ opt(&c)
+ }
+
+ c.telegramOpts.Middlewares = append(c.telegramOpts.Middlewares, c.waiter)
+ if (appID == 0 || appHash == "") && c.creds.IsAvailable() {
+ var err error
+ appID, appHash, err = c.loadCredentials()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ c.cl = telegram.NewClient(appID, appHash, c.telegramOpts)
+
+ return &c, nil
+}
+
+func (c *Client) loadCredentials() (int, string, error) {
+ var err error
+ apiID, apiHash, err := c.creds.Load()
+ if err == nil && apiID > 0 && apiHash != "" {
+ return apiID, apiHash, nil
+ }
+ dlog.Debugf("warning: error loading credentials file, requesting manual input: %s", err)
+ apiID, apiHash, err = c.auth.GetAPICredentials(context.Background())
+ if err != nil {
+ fmt.Println()
+ if errors.Is(io.EOF, err) {
+ return 0, "", errors.New("exit")
+ }
+ return 0, "", err
+ }
+ if err := c.creds.Save(apiID, apiHash); err != nil {
+ // not a fatal error
+ dlog.Debugf("failed to save credentials: %s", err)
+ }
+ return apiID, apiHash, nil
+}
+
+// Start starts the telegram session in goroutine
+func (c *Client) Start(ctx context.Context) error {
+ if c.stop != nil {
+ return ErrAlreadyRunning
+ }
+
+ stop, err := bg.Connect(c.cl)
+ if err != nil {
+ return err
+ }
+ c.stop = stop
+
+ flow := auth.NewFlow(c.auth, c.sendcodeOpts)
+ if err := c.cl.Auth().IfNecessary(ctx, flow); err != nil {
+ if err := c.Stop(); err != nil {
+ dlog.Debugf("error stopping: %s", err)
+ }
+ return err
+ }
+ dlog.Debug("auth success")
+
+ return nil
+}
+
+func (c *Client) Stop() error {
+ if c.stop != nil {
+ if c.waiterStop != nil {
+ defer c.waiterStop()
+ }
+ return c.stop()
+ }
+ return nil
+}
+
+// Run runs an arbitrary telegram session.
+func (c *Client) Run(ctx context.Context, fn func(context.Context, *telegram.Client) error) error {
+ if c.stop != nil {
+ return ErrAlreadyRunning
+ }
+ return c.cl.Run(ctx, func(ctx context.Context) error {
+ return fn(ctx, c.cl)
+ })
+}
diff --git a/internal/mtp/peer_storage.go b/internal/mtp/peer_storage.go
new file mode 100644
index 0000000..972f0e8
--- /dev/null
+++ b/internal/mtp/peer_storage.go
@@ -0,0 +1,129 @@
+package mtp
+
+import (
+ "context"
+ "errors"
+ "sort"
+ "sync"
+
+ "github.com/gotd/contrib/storage"
+)
+
+// MemStorage is the default peer storage for MTP. It uses a map to store all
+// peers, hence, it's not a persistent store.
+type MemStorage struct {
+ s map[string]storage.Peer
+
+ mu sync.RWMutex
+ iterating bool
+
+ // iterator
+ keys []string
+ keyIdx int
+
+ iterErr error
+}
+
+func NewMemStorage() *MemStorage {
+ return &MemStorage{
+ s: make(map[string]storage.Peer, 0),
+ }
+}
+
+func (ms *MemStorage) Add(_ context.Context, value storage.Peer) error {
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+
+ key := storage.KeyFromPeer(value).String()
+ ms.s[key] = value
+ return nil
+}
+
+func (ms *MemStorage) Find(ctx context.Context, key storage.PeerKey) (storage.Peer, error) {
+ return ms.Resolve(ctx, key.String())
+}
+
+func (ms *MemStorage) Assign(_ context.Context, key string, value storage.Peer) error {
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+
+ ms.s[key] = value
+
+ return nil
+}
+
+func (ms *MemStorage) Resolve(_ context.Context, key string) (storage.Peer, error) {
+ ms.mu.RLock()
+ defer ms.mu.RUnlock()
+
+ peer, ok := ms.s[key]
+ if !ok {
+ return storage.Peer{}, storage.ErrPeerNotFound
+ }
+ return peer, nil
+}
+
+func (ms *MemStorage) Iterate(ctx context.Context) (storage.PeerIterator, error) {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ }
+ if ms.IsIterating() {
+ return nil, errors.New("already iterating")
+ }
+
+ // preparing the iterator
+ ms.mu.Lock()
+ ms.keys = make([]string, 0, len(ms.s))
+ for k := range ms.s {
+ ms.keys = append(ms.keys, k)
+ }
+ sort.Strings(ms.keys)
+ ms.keyIdx = -1 // set the passphrase start value
+
+ ms.iterating = true
+ ms.iterErr = nil
+ ms.mu.Unlock()
+
+ // locking for iteration
+ ms.mu.RLock()
+ return ms, nil
+}
+
+func (ms *MemStorage) Next(ctx context.Context) bool {
+ select {
+ case <-ctx.Done():
+ ms.iterErr = ctx.Err()
+ return false
+ default:
+ }
+ ms.keyIdx++
+ return ms.keyIdx < len(ms.keys)
+}
+
+func (ms *MemStorage) Err() error {
+ return ms.iterErr
+}
+
+func (ms *MemStorage) Value() storage.Peer {
+ if !ms.IsIterating() {
+ return storage.Peer{}
+ }
+ return ms.s[ms.keys[ms.keyIdx]]
+}
+
+func (ms *MemStorage) Close() error {
+ if !ms.IsIterating() {
+ return nil
+ }
+ ms.mu.RUnlock()
+ ms.mu.Lock()
+ ms.iterating = false
+ ms.mu.Unlock()
+ return nil
+}
+
+func (ms *MemStorage) IsIterating() bool {
+ return ms.iterating
+}
diff --git a/internal/secure/int.go b/internal/secure/int.go
new file mode 100644
index 0000000..1f111f5
--- /dev/null
+++ b/internal/secure/int.go
@@ -0,0 +1,44 @@
+package secure
+
+import (
+ "bytes"
+ "fmt"
+ "strconv"
+)
+
+type Int int
+
+func (ei Int) String() string {
+ return strconv.Itoa(int(ei))
+}
+
+func (ei Int) MarshalJSON() ([]byte, error) {
+ data, err := Encrypt(ei.String())
+ return []byte(`"` + data + `"`), err
+}
+
+func (ei *Int) UnmarshalJSON(b []byte) error {
+ b = bytes.Trim(b, `"`)
+ if len(b) == 0 {
+ *ei = 0
+ return nil
+ }
+ pt, err := Decrypt(string(b))
+ if err != nil {
+ if err == ErrNotEncrypted {
+ val, err := strconv.Atoi(string(b))
+ if err != nil {
+ return err
+ }
+ *ei = Int(val)
+ return nil
+ }
+ return fmt.Errorf("%w, while decrypting: %q", err, string(b))
+ }
+ val, err := strconv.Atoi(pt)
+ if err != nil {
+ return err
+ }
+ *ei = Int(val)
+ return nil
+}
diff --git a/internal/secure/int_test.go b/internal/secure/int_test.go
new file mode 100644
index 0000000..c65867c
--- /dev/null
+++ b/internal/secure/int_test.go
@@ -0,0 +1,45 @@
+package secure
+
+import (
+ "math"
+ "testing"
+)
+
+func TestInt_MarshalUnmarshalJSON(t *testing.T) {
+ s := newTestKeySentinel()
+ defer s.Reset()
+
+ testcases := []int{123, -1, math.MaxInt}
+ for _, tc := range testcases {
+ val := Int(tc)
+ data, err := val.MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got Int
+ if err := got.UnmarshalJSON(data); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func FuzzMarshalUnmarshalJSON(f *testing.F) {
+ s := newTestKeySentinel()
+ defer s.Reset()
+
+ testcases := []int{123, -1, math.MaxInt}
+ for _, tc := range testcases {
+ f.Add(tc)
+ }
+ f.Fuzz(func(t *testing.T, input int) {
+ val := Int(input)
+ data, err := val.MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got Int
+ if err := got.UnmarshalJSON(data); err != nil {
+ t.Fatal(err)
+ }
+ })
+}
diff --git a/internal/secure/secure.go b/internal/secure/secure.go
new file mode 100644
index 0000000..dd6e434
--- /dev/null
+++ b/internal/secure/secure.go
@@ -0,0 +1,343 @@
+// Package secure provides encryption and decryption functions.
+//
+// Encryption implementation
+//
+// "Salt" is a fixed 256 byte array of pseudo-random values, taken from
+// /dev/urandom.
+//
+// Encryption key is a 256-bit value (32 bytes).
+//
+// Encryption key is derived in the following manner:
+//
+// 1. Repeat bytes of the passphrase to form 32 bytes of the Key
+// 2. Take the first byte of the passphrase and use it for the value of Offset in
+// the Salt array.
+// 3. For each byte of the key, and `i` being the counter:
+// - `Key[i] ^= Salt[(Offset+i)%Key_length]
+//
+// Then the plain text is encrypted with the Key using AES-256 in GCM and signed
+// together with additional data.
+//
+// Then additional data, nonce and ciphertext are packed into the following
+// sequence of bytes:
+//
+// |_|__...__|_________|__...__|
+// ^ ^ ^ ^
+// | | | +- ciphertext, n bytes.
+// | | +---------- nonce, (nonceSz bytes)
+// | +------------------- additinal data, m bytes, (maxDataSz bytes),
+// +------------------------ additional data length value (adlSz bytes).
+//
+// After this, packed byte sequence is armoured with base64 and the signature
+// prefix added to it to distinct it from the plain text.
+package secure
+
+import (
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+)
+
+const (
+ nonceSz = 12 // bytes, nonce sz
+ keyBits = 256 // encryption gKey size.
+ keySz = keyBits / 8 // bytes, gKey size
+ adlSz = 1 // bytes, size of additional data length field
+ maxDataSz = 1<<(adlSz*8) - 1 // bytes, max additional data size (this is maximum that can fit into (adlSz) bytes)
+
+ signature = "TGD." // used to identified encrypted strings
+ sigSz = len(signature)
+)
+
+// salt will be used to XOR the gKey which we generate by padding the passphrase.
+var salt = [keySz * 8]byte{
+ 0x1a, 0x98, 0x15, 0x70, 0xbf, 0x57, 0x16, 0x35, 0xba, 0x78, 0x1e, 0xbc,
+ 0x97, 0x09, 0x24, 0x47, 0xe7, 0xa6, 0xac, 0x72, 0x0d, 0x60, 0x28, 0x8b,
+ 0x40, 0x13, 0x02, 0x0d, 0xd6, 0x38, 0xa3, 0xfa, 0x95, 0x14, 0xc6, 0x7d,
+ 0x65, 0x3d, 0xb2, 0xd9, 0x86, 0x4f, 0x61, 0x5f, 0xa5, 0xe7, 0xdc, 0x30,
+ 0x52, 0x49, 0x0c, 0x6d, 0x1a, 0xea, 0x2b, 0x5b, 0xf6, 0x4a, 0x5f, 0xd2,
+ 0xfd, 0x01, 0x1a, 0xc8, 0x48, 0x68, 0xcf, 0x7b, 0xfa, 0x64, 0xc7, 0x46,
+ 0x82, 0xdc, 0x78, 0xb6, 0xc0, 0x80, 0x07, 0xb5, 0xa0, 0x79, 0x3f, 0xcb,
+ 0xe5, 0xee, 0x55, 0x72, 0x74, 0x66, 0x6d, 0xe4, 0x8e, 0xed, 0xd1, 0xff,
+ 0xba, 0x6b, 0x51, 0xf7, 0xca, 0xfe, 0x43, 0x3f, 0xbd, 0x37, 0xb5, 0x37,
+ 0xa3, 0xa4, 0x05, 0x44, 0xd4, 0x1f, 0xb9, 0xd9, 0xc0, 0x2f, 0x41, 0xa6,
+ 0xe9, 0x14, 0x6b, 0xef, 0xdd, 0x67, 0x0d, 0x5e, 0x10, 0x31, 0xca, 0xdc,
+ 0xd1, 0x42, 0xdd, 0x9d, 0xef, 0x14, 0x7f, 0xff, 0x4d, 0x03, 0x65, 0xdc,
+ 0x66, 0x5d, 0x92, 0x4c, 0x23, 0x89, 0xf7, 0x62, 0x9d, 0x2a, 0x06, 0xe1,
+ 0x66, 0x0a, 0x47, 0x24, 0xd3, 0x08, 0xc1, 0x04, 0x45, 0xb5, 0xcd, 0x1c,
+ 0x61, 0x08, 0x52, 0xf5, 0x4e, 0xb8, 0xbd, 0x47, 0x69, 0x30, 0xec, 0x02,
+ 0x61, 0xf9, 0xd8, 0xc9, 0x93, 0x20, 0x8b, 0x33, 0xe9, 0x96, 0xab, 0xd4,
+ 0x43, 0x91, 0x59, 0xe0, 0x4e, 0x45, 0x5c, 0xda, 0x57, 0x0e, 0x12, 0x77,
+ 0xa4, 0xe2, 0x0d, 0x7e, 0xee, 0xe3, 0x2e, 0x80, 0x98, 0x39, 0xd1, 0x98,
+ 0x34, 0x4e, 0x3f, 0xff, 0xcf, 0xca, 0x1f, 0xe6, 0x36, 0xfc, 0x58, 0x12,
+ 0xfd, 0x8e, 0x28, 0x83, 0x74, 0xbc, 0xf9, 0xeb, 0xf8, 0xd3, 0x4f, 0x39,
+ 0x35, 0x74, 0x5d, 0xa7, 0x65, 0x64, 0x0b, 0x13, 0x38, 0x0e, 0x4b, 0x63,
+ 0xcf, 0x47, 0x64, 0xf2,
+}
+
+var (
+ ErrNotEncrypted = errors.New("string not encrypted")
+ ErrNoEncryptionKey = errors.New("no encryption gKey")
+ ErrDataOverflow = errors.New("additional data overflow")
+ ErrInvalidKeySz = errors.New("invalid Key size")
+)
+
+// CipherError indicates that there was an error during decrypting of
+// ciphertext.
+type CipherError struct {
+ Err error
+}
+
+func (e *CipherError) Error() string {
+ return e.Err.Error()
+}
+
+func (e *CipherError) Unwrap() error {
+ return e.Err
+}
+
+func (e *CipherError) Is(target error) bool {
+ t, ok := target.(*CipherError)
+ if !ok {
+ return false
+ }
+ return e.Err.Error() == t.Err.Error()
+}
+
+type CorruptError struct {
+ Value []byte
+}
+
+func (e *CorruptError) Error() string {
+ return "corrupt packed data"
+}
+
+func (e *CorruptError) Is(target error) bool {
+ t, ok := target.(*CorruptError)
+ if !ok {
+ return false
+ }
+ return bytes.Equal(t.Value, e.Value)
+}
+
+var gKey []byte
+
+// setGlobalKey sets the encryption gKey globally.
+func setGlobalKey(k []byte) error {
+ if len(k) != keySz {
+ return ErrInvalidKeySz
+ }
+ gKey = k
+ return nil
+}
+
+func SetPassphrase(b []byte) error {
+ k, err := deriveKey(b)
+ if err != nil {
+ return err
+ }
+ return setGlobalKey(k)
+}
+
+// deriveKey interpolates the passphrase value to the gKey size and xors it with salt.
+func deriveKey(pass []byte) ([]byte, error) {
+ if len(pass) == 0 {
+ return nil, errors.New("empty passphrase")
+ }
+ if len(pass) > keySz {
+ return nil, errors.New("passphrase is too big")
+ }
+
+ var key = make([]byte, keySz)
+ var startOffset = int(pass[0]) // starting offset in salt is the first byte of the password
+
+ for i := range key {
+ key[i] = pass[i%len(pass)] ^ salt[(i+startOffset)%len(salt)]
+ }
+ return key, nil
+}
+
+// Encrypt encrypts the plain text password to use in the configuration file
+// with the gKey generated by KeyFn.
+func Encrypt(plaintext string) (string, error) {
+ return encrypt(plaintext, gKey, nil)
+}
+
+// Decrypt attempts to decrypt the string and return the password.
+// In case s is not an encrypted string, ErrNotEncrypted returned along with
+// original string.
+func Decrypt(s string) (string, error) {
+ return decrypt(s, gKey)
+}
+
+// EncryptWithPassphrase encrypts plaintext with the provided passphrase
+func EncryptWithPassphrase(plaintext string, passphrase []byte) (string, error) {
+ key, err := deriveKey(passphrase)
+ if err != nil {
+ return "", err
+ }
+ return encrypt(plaintext, key, nil)
+}
+
+// DecryptWithPassphrase attempts to descrypt string with the provided MAC
+// address.
+func DecryptWithPassphrase(s string, passphrase []byte) (string, error) {
+ key, err := deriveKey(passphrase)
+ if err != nil {
+ return "", err
+ }
+ return decrypt(s, key)
+}
+
+// Encrypt encrypts the plain text password to use in the configuration file.
+func encrypt(plaintext string, key []byte, additionalData []byte) (string, error) {
+ if len(key) == 0 {
+ return "", ErrNoEncryptionKey
+ }
+ if len(key) != keySz {
+ return "", ErrInvalidKeySz
+ }
+ if len(plaintext) == 0 {
+ return "", errors.New("nothing to encrypt")
+ }
+ if len(additionalData) > maxDataSz {
+ return "", fmt.Errorf("size of additional data can't exceed %d B", maxDataSz)
+ }
+
+ gcm, err := initGCM(key)
+ if err != nil {
+ return "", err
+ }
+
+ nonce := make([]byte, nonceSz)
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", err
+ }
+ ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), additionalData)
+
+ // return signature + base64.StdEncoding.EncodeToString(data), nil
+ packed, err := pack(ciphermsg{nonce, ciphertext, additionalData})
+ if err != nil {
+ return "", err
+ }
+
+ return armor(packed), nil
+}
+
+// initGCM initialises the Galois/Counter Mode
+func initGCM(key []byte) (cipher.AEAD, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ return cipher.NewGCM(block)
+}
+
+func pack(cm ciphermsg) ([]byte, error) {
+ if len(cm.nonce) == 0 {
+ return nil, errors.New("pack: empty nonce")
+ }
+ if len(cm.ciphertext) == 0 {
+ return nil, errors.New("pack: no ciphertext")
+ }
+ dataLen := len(cm.additionalData)
+ if dataLen > maxDataSz {
+ return nil, ErrDataOverflow
+ }
+
+ packed := make([]byte, nonceSz+len(cm.ciphertext)+1+dataLen)
+ packed[0] = byte(dataLen)
+ if dataLen > 0 {
+ copy(packed[adlSz:], cm.additionalData)
+ }
+ copy(packed[adlSz+dataLen:], cm.nonce)
+ copy(packed[adlSz+dataLen+nonceSz:], cm.ciphertext)
+
+ return packed, nil
+}
+
+func armor(packed []byte) string {
+ return signature + base64.StdEncoding.EncodeToString(packed)
+}
+
+func unarmor(s string) ([]byte, error) {
+ s = strings.TrimSpace(s)
+ if len(s) < sigSz || s[0:sigSz] != signature {
+ return nil, ErrNotEncrypted
+ }
+ packed, err := base64.StdEncoding.DecodeString(s[sigSz:])
+ if err != nil {
+ return nil, err
+ }
+ return packed, nil
+}
+
+type ciphermsg struct {
+ nonce []byte
+ ciphertext []byte
+ additionalData []byte
+}
+
+func unpack(packed []byte) (*ciphermsg, error) {
+ if len(packed) == 0 {
+ return nil, errors.New("unpack: empty input")
+ }
+ var (
+ dataLen = int(packed[0])
+ payloadSz = len(packed) - adlSz - nonceSz // payload is data + ct size
+ )
+ if dataLen > payloadSz || payloadSz-dataLen == 0 {
+ return nil, &CorruptError{packed}
+ }
+ cm := &ciphermsg{
+ nonce: packed[adlSz+dataLen : adlSz+dataLen+nonceSz],
+ ciphertext: packed[adlSz+dataLen+nonceSz:],
+ }
+ if dataLen > 0 {
+ cm.additionalData = packed[adlSz : adlSz+dataLen]
+ }
+ return cm, nil
+}
+
+func decrypt(s string, key []byte) (string, error) {
+ packed, err := unarmor(s)
+ if err != nil {
+ if err == ErrNotEncrypted {
+ return s, err
+ }
+ return "", err // other error
+ }
+ if len(key) == 0 {
+ return "", ErrNoEncryptionKey
+ }
+ cm, err := unpack(packed)
+ if err != nil {
+ return "", err
+ }
+ aesgcm, err := initGCM(key)
+ if err != nil {
+ return "", err
+ }
+
+ plaintext, err := aesgcm.Open(nil, cm.nonce, cm.ciphertext, cm.additionalData)
+ if err != nil {
+ return "", &CipherError{err}
+ }
+ return string(plaintext), nil
+}
+
+// IsDecryptError returns true if there was a decryption error or corrupt data
+// error and false if it's a different kind of error.
+func IsDecryptError(err error) bool {
+ switch err.(type) {
+ case *CipherError, *CorruptError:
+ return true
+ }
+ return false
+}
diff --git a/internal/secure/secure_test.go b/internal/secure/secure_test.go
new file mode 100644
index 0000000..39f2107
--- /dev/null
+++ b/internal/secure/secure_test.go
@@ -0,0 +1,390 @@
+package secure
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "log"
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+const (
+ encryptedPlainText = "TGD.APO/yw5Y6DjATD6ShhbAH/mBRYLXgV09wSUT5YJ82UgU/98iCQBx"
+)
+
+var testPassphrase = []byte{0, 0, 0, 0, 0, 0}
+
+func TestEncryptPlainText(t *testing.T) {
+ out, err := EncryptWithPassphrase("plain text", testPassphrase)
+ if err != nil {
+ fmt.Println("brokeh:", err)
+ return
+ }
+ t.Log(out)
+ // Output:
+}
+
+func testNonce(b byte) []byte {
+ var n = make([]byte, nonceSz)
+ for i := range n {
+ n[i] = b
+ }
+ return n
+}
+
+func Test_deriveKey(t *testing.T) {
+ type args struct {
+ pass []byte
+ }
+ tests := []struct {
+ name string
+ args args
+ want []byte
+ wantErr bool
+ }{
+ {"zero", args{testPassphrase}, salt[:keyBits/8], false},
+ {"offset 1",
+ args{[]byte{1, 0, 0, 0, 0, 0}},
+ // salt bytes, offset 1, every 6-th byte is XORed with 0x01:
+ []byte{0x99, 0x15, 0x70, 0xbf, 0x57, 0x16, 0x34, 0xba, 0x78, 0x1e, 0xbc, 0x97, 0x8, 0x24, 0x47, 0xe7, 0xa6, 0xac, 0x73, 0xd, 0x60, 0x28, 0x8b, 0x40, 0x12, 0x2, 0xd, 0xd6, 0x38, 0xa3, 0xfb, 0x95},
+ false,
+ },
+ {"empty pass", args{nil}, nil, true},
+ {"invalid len", args{make([]byte, keyBits/8+1)}, nil, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := deriveKey(tt.args.pass)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("deriveKey() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("deriveKey() got = %#v, want %#v", got, tt.want)
+ }
+ })
+ }
+}
+
+// to reset it's value.
+type keySentinel struct {
+ oldKey []byte
+}
+
+// newKeySentinel sets the global gKey to the specified value. Call Reset() on
+// the sentinel to reset the initial variable value.
+func newKeySentinel(k []byte) keySentinel {
+ m := keySentinel{gKey}
+ if err := setGlobalKey(k); err != nil {
+ panic(err)
+ }
+ return m
+}
+
+// Reset resets the old value of KeyFromHwAddr
+func (m keySentinel) Reset() {
+ if err := setGlobalKey(m.oldKey); err != nil {
+ log.Printf("this is ok: %s", err)
+ }
+}
+
+// newTestKeySentinel sets the gKey to test password
+func newTestKeySentinel() keySentinel {
+ k, err := deriveKey(testPassphrase)
+ if err != nil {
+ panic(err)
+ }
+ return newKeySentinel(k)
+}
+
+func Test_Encryption(t *testing.T) {
+ const testPT = "plain text"
+
+ m := newTestKeySentinel()
+ defer m.Reset()
+
+ key, err := deriveKey(testPassphrase)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ct, err := encrypt(testPT, key, []byte("123"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Log(ct)
+ pt, err := decrypt(ct, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assert.Equal(t, testPT, pt)
+}
+
+func Test_EncryptDecryptWithPassphrase(t *testing.T) {
+ const testPT = "plain text"
+
+ ct, err := EncryptWithPassphrase(testPT, []byte("1234567890"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Log(ct)
+ pt, err := DecryptWithPassphrase(ct+" ", []byte("1234567890"))
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, testPT, pt)
+
+ // trying to decrypt with different passphrase should return error
+ pt, err = DecryptWithPassphrase(ct, []byte("11:22:33:44:55:66"))
+ if err == nil {
+ t.Errorf("should have failed to decrypt, but did not, pt=%v", pt)
+ }
+}
+
+func TestDecrypt(t *testing.T) {
+ z := newTestKeySentinel()
+ defer z.Reset()
+
+ type args struct {
+ s string
+ }
+
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr bool
+ }{
+ {"encrypted password", args{encryptedPlainText}, "plain text", false},
+ {"trim", args{" " + encryptedPlainText + "\n"}, "plain text", false},
+ {"invalid base64", args{encryptedPlainText[:len(encryptedPlainText)-1]}, "", true},
+ {"non-encrypted password", args{"plain text"}, "plain text", true},
+ {"signature, but non-encrypted (error)", args{signature + "plain text"}, "", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Decrypt(tt.args.s)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Decrypt() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("Decrypt() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+var (
+ validPacked = bytesjoin([]byte{3, 1, 2, 3}, testNonce(0xcc), []byte{4, 5, 6})
+ validCm = ciphermsg{
+ additionalData: []byte{1, 2, 3},
+ nonce: testNonce(0xcc),
+ ciphertext: []byte{4, 5, 6},
+ }
+)
+
+func Test_pack(t *testing.T) {
+ type args struct {
+ cm ciphermsg
+ }
+ tests := []struct {
+ name string
+ args args
+ want []byte
+ wantErr bool
+ }{
+ {"packing ok",
+ args{validCm},
+ validPacked,
+ false,
+ },
+ {"data too big",
+ args{ciphermsg{
+ additionalData: make([]byte, maxDataSz+1),
+ nonce: testNonce(0xcc),
+ ciphertext: []byte{4, 5, 6}}},
+ nil,
+ true,
+ },
+ {"empty additional data",
+ args{ciphermsg{
+ additionalData: nil,
+ nonce: testNonce(0xcc),
+ ciphertext: []byte{255, 254, 253},
+ }},
+ bytesjoin([]byte{0}, testNonce(0xcc), []byte{255, 254, 253}),
+ false,
+ },
+ {"empty nonce",
+ args{ciphermsg{
+ additionalData: []byte{1, 2, 3},
+ nonce: nil,
+ ciphertext: []byte{255, 254, 253},
+ }},
+ nil,
+ true,
+ },
+ {"empty ct",
+ args{ciphermsg{
+ additionalData: []byte{1, 2, 3},
+ nonce: testNonce(0xcc),
+ ciphertext: nil,
+ }},
+ nil,
+ true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := pack(tt.args.cm)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("pack() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !bytes.Equal(got, tt.want) {
+ t.Errorf("pack() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_unpack(t *testing.T) {
+ type args struct {
+ packed []byte
+ }
+ tests := []struct {
+ name string
+ args args
+ want *ciphermsg
+ wantErr bool
+ }{
+ {"ok",
+ args{validPacked},
+ &validCm,
+ false,
+ },
+ {"empty input", args{}, nil, true},
+ {"invalid data length",
+ args{bytesjoin([]byte{6, 1, 2, 3}, testNonce(0xcc), []byte{4, 5, 6})},
+ nil,
+ true,
+ },
+ {"empty data",
+ args{bytesjoin([]byte{0}, testNonce(0xcc), []byte{4, 5, 6})},
+ &ciphermsg{
+ additionalData: nil,
+ nonce: testNonce(0xcc),
+ ciphertext: []byte{4, 5, 6},
+ },
+ false,
+ },
+ {"empty CT",
+ args{bytesjoin([]byte{1, 0xdd}, testNonce(0xcc))},
+ nil,
+ true,
+ },
+ {"empty everything except data",
+ args{[]byte{1, 0xdd}},
+ nil,
+ true,
+ },
+ {"nothing to do",
+ args{[]byte{0}},
+ nil,
+ true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := unpack(tt.args.packed)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("unpack() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("unpack() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+// bytejoin aims to declutter the bytes.Join call in tests.
+func bytesjoin(bb ...[]byte) []byte {
+ return bytes.Join(bb, []byte{})
+}
+
+func Test_armor(t *testing.T) {
+ type args struct {
+ packed []byte
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ }{
+ {"ok", args{validPacked}, signature + "AwECA8zMzMzMzMzMzMzMzAQFBg=="},
+ {"another one", args{}, signature},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := armor(tt.args.packed); got != tt.want {
+ t.Errorf("armor() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_unarmor(t *testing.T) {
+ type args struct {
+ s string
+ }
+ tests := []struct {
+ name string
+ args args
+ want []byte
+ wantErr bool
+ }{
+ {"plain text", args{"some text"}, nil, true},
+ {"illegal base64", args{signature + "hey you"}, nil, true},
+ {"armored data", args{signature + "AwECA8zMzMzMzMzMzMzMzAQFBg=="}, validPacked, false},
+ {"empty text", args{""}, nil, true},
+ {"trim space", args{" " + signature + "AwECA8zMzMzMzMzMzMzMzAQFBg== "}, validPacked, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := unarmor(tt.args.s)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("unarmor() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("unarmor() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsDecryptError(t *testing.T) {
+ type args struct {
+ err error
+ }
+ tests := []struct {
+ name string
+ args args
+ want bool
+ }{
+ {"cipher", args{&CipherError{nil}}, true},
+ {"corrupt", args{&CorruptError{nil}}, true},
+ {"other", args{errors.New("your shotgun is nearby")}, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := IsDecryptError(tt.args.err); got != tt.want {
+ t.Errorf("IsDecryptError() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/secure/string.go b/internal/secure/string.go
new file mode 100644
index 0000000..3e2bcd9
--- /dev/null
+++ b/internal/secure/string.go
@@ -0,0 +1,39 @@
+package secure
+
+import (
+ "bytes"
+ "fmt"
+)
+
+// String is a type of encrypted string. Surprise.
+type String string
+
+func (es String) String() string {
+ return string(es)
+}
+
+func (es String) MarshalJSON() ([]byte, error) {
+ if len(es) == 0 {
+ return []byte(`""`), nil
+ }
+ data, err := Encrypt(string(es))
+ return []byte(`"` + data + `"`), err
+}
+
+func (es *String) UnmarshalJSON(b []byte) error {
+ b = bytes.Trim(b, `"`)
+ if len(b) == 0 {
+ *es = ""
+ return nil
+ }
+ pt, err := Decrypt(string(b))
+ if err != nil {
+ if err == ErrNotEncrypted {
+ *es = String(b)
+ return nil
+ }
+ return fmt.Errorf("%w, while decrypting: %q", err, string(b))
+ }
+ *es = String(pt)
+ return nil
+}
diff --git a/internal/secure/string_test.go b/internal/secure/string_test.go
new file mode 100644
index 0000000..e888ff2
--- /dev/null
+++ b/internal/secure/string_test.go
@@ -0,0 +1,108 @@
+package secure
+
+import (
+ "bytes"
+ "encoding/json"
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+const sampleJson = `
+{
+ "id": 24,
+ "username": "hide_ur_pain",
+ "secret": "` + encryptedPlainText + `"
+}
+`
+
+type testType struct {
+ ID int `json:"id"`
+ Username string `json:"username"`
+ Secret String `json:"secret"`
+}
+
+var testStruct = testType{
+ ID: 24,
+ Username: "hide_ur_pain",
+ Secret: "plain text",
+}
+
+func TestString_UnmarshalJSONtoStruct(t *testing.T) {
+ z := newTestKeySentinel()
+ defer z.Reset()
+
+ var got testType
+ err := json.Unmarshal([]byte(sampleJson), &got)
+ assert.NoError(t, err, "Unmarshal() unexpected error")
+ assert.Equal(t, testStruct, got)
+}
+
+func TestString_UnmarshalJSON(t *testing.T) {
+ z := newTestKeySentinel()
+ defer z.Reset()
+
+ type args struct {
+ b []byte
+ }
+ tests := []struct {
+ name string
+ args args
+ wantES String
+ wantErr bool
+ }{
+ {"unencrypted", args{[]byte("unencrypted")}, "unencrypted", false},
+ {"unencrypted quoted", args{[]byte("\"unencrypted\"")}, "unencrypted", false},
+ {"encrypted", args{[]byte(encryptedPlainText)}, "plain text", false},
+ {"encrypted quoted", args{[]byte(`"` + encryptedPlainText + `"`)}, "plain text", false},
+ {"invalid", args{[]byte(signature + "i must break you")}, "", true},
+ {"empty", args{[]byte{}}, "", false},
+ {"empty quoted", args{[]byte(`""`)}, "", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var es String
+ if err := es.UnmarshalJSON(tt.args.b); (err != nil) != tt.wantErr {
+ t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ assert.Equal(t, string(tt.wantES), string(es))
+ })
+ }
+}
+
+func TestString_MarshalJSON(t *testing.T) {
+ z := newTestKeySentinel()
+ defer z.Reset()
+
+ tests := []struct {
+ name string
+ es String
+ want string
+ wantErr bool
+ }{
+ {"plain text", String("plain text"), "plain text", false},
+ {"empty", String(""), ``, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ encrypted, err := tt.es.MarshalJSON()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ encrypted = bytes.Trim(encrypted, `"`)
+ if len(encrypted) == 0 && len(tt.want) == 0 {
+ return // all good, empty string is an empty string.
+ }
+ got, err := Decrypt(string(encrypted))
+ if err != nil {
+ t.Errorf("unexpected decrypt error: %s", err)
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("MarshalJSON() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/sys/sys.go b/internal/sys/sys.go
new file mode 100644
index 0000000..a099dfc
--- /dev/null
+++ b/internal/sys/sys.go
@@ -0,0 +1,35 @@
+package sys
+
+import (
+ "errors"
+ "net"
+)
+
+// Errors.
+var (
+ ErrNetNoIface = errors.New("unable find a suitable interface")
+)
+
+// FindIface returns name of the first active interface with non-nil MAC
+// address.
+func FindIface() (string, error) {
+ interfaces, err := net.Interfaces()
+ if err != nil {
+ return "", err
+ }
+ for _, i := range interfaces {
+ if i.Flags&net.FlagUp != 0 && len(i.HardwareAddr) > 0 {
+ return i.Name, nil
+ }
+ }
+ return "", ErrNetNoIface
+}
+
+// IfaceMAC returns the MAC address for the Interface.
+func IfaceMAC(name string) (net.HardwareAddr, error) {
+ iface, err := net.InterfaceByName(name)
+ if err != nil {
+ return nil, err
+ }
+ return iface.HardwareAddr, nil
+}
diff --git a/internal/sys/sys_test.go b/internal/sys/sys_test.go
new file mode 100644
index 0000000..fbdbc41
--- /dev/null
+++ b/internal/sys/sys_test.go
@@ -0,0 +1,36 @@
+package sys
+
+import (
+ "bytes"
+ "testing"
+)
+
+var testMAC = []byte{0, 0, 0, 0, 0, 0}
+
+func TestFindIface(t *testing.T) {
+ t.Run("normal run", func(t *testing.T) {
+ nif, err := FindIface()
+ if err != nil {
+ t.Errorf("unexpected error: %s", err)
+ }
+ if nif == "" {
+ t.Errorf("expected interface name, got: %q", nif)
+ }
+ })
+}
+
+func TestIfaceMAC(t *testing.T) {
+ t.Run("mac for real interface", func(t *testing.T) {
+ nif, err := FindIface()
+ if err != nil {
+ t.Errorf("FindIface() unexpected error: %s", err)
+ }
+ mac, err := IfaceMAC(nif)
+ if err != nil {
+ t.Fatalf("IfaceMAC() unexpected error: %s", err)
+ }
+ if len(mac) == 0 || bytes.Equal(testMAC, mac) {
+ t.Errorf("unexpected mac value: %v", mac)
+ }
+ })
+}
diff --git a/internal/tui/app.go b/internal/tui/app.go
new file mode 100644
index 0000000..1ef5ab1
--- /dev/null
+++ b/internal/tui/app.go
@@ -0,0 +1,137 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/gotd/td/telegram/query/messages"
+ "github.com/looplab/fsm"
+ "github.com/rivo/tview"
+ "github.com/rusq/dlog"
+ "github.com/rusq/osenv/v2"
+
+ "github.com/rusq/wipemychat/internal/mtp"
+)
+
+const (
+ btnYes = "Yes"
+ btnNo = "No"
+ btnOK = "OK"
+)
+
+type App struct {
+ tva *tview.Application
+ tg telegramer
+ log *dlog.Logger
+ fsm *fsm.FSM
+
+ pages *tview.Pages
+ view views
+}
+
+type telegramer interface {
+ GetChats(ctx context.Context) ([]mtp.Entity, error)
+ SearchAllMyMessages(ctx context.Context, dlg mtp.Entity, cb func(n int)) ([]messages.Elem, error)
+ DeleteMessages(ctx context.Context, dlg mtp.Entity, messages []messages.Elem) (int, error)
+}
+
+type views struct {
+ main *tview.Flex
+ mbConfirm *tview.Modal
+ mbNothing *tview.Modal
+ fmSearch *tview.Form
+
+ lvChats *tview.List
+ tvLog *tview.TextView
+}
+
+func New(tg telegramer) *App {
+ app := &App{
+ tva: tview.NewApplication(),
+ tg: tg,
+
+ pages: tview.NewPages(),
+ view: views{
+ main: tview.NewFlex(),
+ mbConfirm: tview.NewModal(),
+ mbNothing: tview.NewModal(),
+ fmSearch: tview.NewForm(),
+
+ lvChats: tview.NewList(),
+ tvLog: tview.NewTextView(),
+ },
+ }
+
+ app.initMain()
+ app.initFind()
+ app.initConfirm()
+ app.initNothing()
+
+ app.tva.SetInputCapture(app.handleKeystrokes)
+
+ app.log = dlog.New(app.view.tvLog, "", dlog.Flags(), osenv.Value("DEBUG", "") != "")
+
+ // init finite state machine
+ app.fsm = initFSM(app)
+
+ return app
+}
+
+func (app *App) Run(ctx context.Context, chats []mtp.Entity) error {
+ app.populateChatList(ctx, chats)
+
+ if err := app.tva.SetRoot(app.pages, true).EnableMouse(false).Run(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (app *App) logf(format string, a ...any) {
+ app.log.Printf(format, a...)
+}
+
+func (app *App) error(err error) {
+ app.log.Printf("ERROR: %s", err)
+}
+
+func (app *App) handleKeystrokes(event *tcell.EventKey) *tcell.EventKey {
+ if app.fsm.Current() == stDeleting {
+ // we do not process keystrokes until deletion is finished.
+ return event
+ }
+
+ switch event.Key() {
+ case tcell.KeyCtrlQ, tcell.KeyF10:
+ app.tva.Stop()
+ default:
+ return event
+ }
+ return nil
+}
+
+// cancel sends a evCancelled event.
+func (app *App) cancel() {
+ app.event(evCancelled)
+}
+
+// event sends an event to FSM, will return true, if there were no errors.
+func (app *App) event(event string) bool {
+ if err := app.fsm.Event(event); err != nil {
+ app.error(err)
+ return false
+ }
+ return true
+}
+
+func (app *App) printf(format string, a ...any) {
+ _, _ = fmt.Fprintf(app.view.tvLog, format, a...)
+}
+
+// modal wraps a primitive in a modal box.
+func modal(p tview.Primitive, width int, height int) tview.Primitive {
+ return tview.NewGrid().
+ SetColumns(0, width, 0).
+ SetRows(0, height, 0).
+ AddItem(p, 1, 1, 1, 1, 0, 0, true)
+}
diff --git a/internal/tui/chatlist.go b/internal/tui/chatlist.go
new file mode 100644
index 0000000..2b17816
--- /dev/null
+++ b/internal/tui/chatlist.go
@@ -0,0 +1,136 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/rusq/wipemychat/internal/mtp"
+)
+
+const infoText = "Press [Ctrl+Q] or [F10] to quit, [Ctrl+F] or [/] to search chats"
+
+func (app *App) initMain() {
+ app.view.lvChats.
+ SetHighlightFullLine(true).
+ SetSelectedBackgroundColor(tcell.Color190).
+ SetSelectedTextColor(tcell.ColorBlack).
+ SetMainTextColor(tcell.Color190).
+ ShowSecondaryText(true).
+ SetBorder(true).
+ SetInputCapture(app.chatInputCapture).
+ SetTitle("[ Chats ]")
+
+ app.view.tvLog.
+ SetWordWrap(true).
+ SetScrollable(true).
+ SetChangedFunc(func() { app.tva.Draw() }).
+ SetBorder(true).
+ SetTitle("[ Information ]")
+
+ // main is the main screen, split in two parts.
+ workspace := tview.NewFlex().
+ AddItem(app.view.lvChats, 0, 25, true).
+ AddItem(app.view.tvLog, 0, 75, false)
+
+ // The bottom row is the help message
+ info := tview.NewTextView().
+ SetDynamicColors(true).
+ SetWrap(false).
+ SetTextAlign(tview.AlignCenter).
+ SetTextColor(tcell.ColorRed).
+ SetText(infoText)
+
+ mainScreen := tview.NewFlex().
+ SetDirection(tview.FlexRow).
+ AddItem(workspace, 0, 1, true).
+ AddItem(info, 1, 1, false)
+
+ app.pages.AddPage(stSelecting, mainScreen, true, true)
+
+}
+
+func (app *App) populateChatList(ctx context.Context, chats []mtp.Entity) {
+ for _, chat := range chats {
+ app.view.lvChats.AddItem(
+ chat.GetTitle(),
+ fmt.Sprintf(" %s (%d)", chat.TypeInfo().Name, chat.GetID()),
+ 0,
+ func() { app.handleChats(ctx, chats) },
+ )
+ }
+}
+
+func (app *App) handleChats(ctx context.Context, chats []mtp.Entity) {
+ if !app.event(evSelected) {
+ return
+ }
+
+ selected := chats[app.view.lvChats.GetCurrentItem()]
+ // async fetch is needed so that the tvLog will keep updating.
+ go app.runDelete(selected)
+}
+
+func (app *App) runDelete(selected mtp.Entity) {
+ // disable input on lvChats
+ app.view.lvChats.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { return nil })
+ defer func() {
+ // once finished collecting information, reenable the input.
+ app.view.lvChats.SetInputCapture(app.chatInputCapture)
+ }()
+ app.view.tvLog.Clear()
+
+ app.logf("Scanning chat: %s, please wait...", selected.GetTitle())
+ total := 0
+ msgs, err := app.tg.SearchAllMyMessages(context.Background(), selected, func(n int) {
+ total += n
+ if total > 0 && total%100 == 0 {
+ app.printf("...%d", total)
+ }
+ })
+ if total > 0 {
+ app.printf("...%d\n", total)
+ }
+ if err != nil {
+ app.error(err)
+ app.cancel()
+ return
+ }
+ app.logf("Scan complete, found %d messages", len(msgs))
+
+ if len(msgs) == 0 {
+ // show nothing to do message.
+ if !app.event(evNothingToDo) {
+ app.cancel()
+ }
+ return
+ }
+
+ app.fsm.SetMetadata(metaChat, selected)
+ app.fsm.SetMetadata(metaMessages, msgs)
+ app.view.mbConfirm.SetText(fmt.Sprintf("Found %d messages in %q. Delete?", len(msgs), selected.GetTitle()))
+
+ if !app.event(evFetched) {
+ app.cancel()
+ return
+ }
+}
+
+func (app *App) chatInputCapture(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() {
+ case tcell.KeyCtrlF:
+ if !app.event(evSearch) {
+ return event
+ }
+ case tcell.KeyRune:
+ switch event.Rune() {
+ case '/':
+ if !app.event(evSearch) {
+ return event
+ }
+ }
+ }
+ return event
+}
diff --git a/internal/tui/confirm.go b/internal/tui/confirm.go
new file mode 100644
index 0000000..5589889
--- /dev/null
+++ b/internal/tui/confirm.go
@@ -0,0 +1,66 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/gotd/td/telegram/query/messages"
+
+ "github.com/rusq/wipemychat/internal/mtp"
+)
+
+func (app *App) initConfirm() {
+ app.pages.AddPage(stConfirming, app.view.mbConfirm, false, false)
+ app.view.mbConfirm.
+ AddButtons([]string{btnYes, btnNo}).
+ SetDoneFunc(app.handleConfirm).
+ SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyESC {
+ app.cancel()
+ return nil
+ }
+ return event
+ })
+}
+
+func (app *App) handleConfirm(_ int, buttonLabel string) {
+ var err error
+ switch buttonLabel {
+ case btnYes:
+ if !app.event(evConfirmed) {
+ return
+ }
+ err = app.handleDelete()
+ case btnNo:
+ app.cancel()
+ default:
+ err = nil
+ }
+ if err != nil {
+ app.error(err)
+ }
+}
+
+// handleDelete handles the deletion of the messages. It gets the chat
+// and messages to delete from the FSM Metadata.
+func (app *App) handleDelete() error {
+ defer app.event(evDeleted)
+ chat, err := metadata[mtp.Entity](app.fsm, metaChat)
+ if err != nil {
+ return fmt.Errorf("chat missing: %s", err)
+ }
+
+ msgs, err := metadata[[]messages.Elem](app.fsm, metaMessages)
+ if err != nil {
+ return fmt.Errorf("messages missing: %s", err)
+ }
+ app.logf("Deleting %d messages from %s, please wait . . .", len(msgs), chat.GetTitle())
+ n, err := app.tg.DeleteMessages(context.Background(), chat, msgs)
+ if err != nil {
+ return err
+ }
+ app.logf("%d messages deleted in %q", n, chat.GetTitle())
+
+ return nil
+}
diff --git a/internal/tui/find.go b/internal/tui/find.go
new file mode 100644
index 0000000..9bae891
--- /dev/null
+++ b/internal/tui/find.go
@@ -0,0 +1,51 @@
+package tui
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+func (app *App) initFind() {
+ app.pages.AddPage(stSearching, modal(app.view.fmSearch, 60, 5), true, false)
+ input := tview.NewInputField().SetLabel("Search")
+ app.view.fmSearch.
+ AddFormItem(input).
+ SetBorder(true).
+ SetTitle("[ Find Chat ]").
+ SetBackgroundColor(tcell.ColorDarkCyan).
+ SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() {
+ case tcell.KeyCR:
+ app.findChat()
+ input.SetText("")
+ return nil
+ case tcell.KeyESC:
+ app.cancel()
+ return nil
+ }
+ return event
+ })
+}
+
+func (app *App) findChat() {
+ val := app.view.fmSearch.GetFormItem(0).(*tview.InputField)
+
+ text := val.GetText()
+ if text == "" {
+ app.logf("search input is empty")
+ app.cancel()
+ return
+ }
+
+ loc := app.view.lvChats.FindItems(text, text, false, true)
+ if len(loc) == 0 {
+ app.logf("search term not found: %q", text)
+ app.cancel()
+ return
+ }
+
+ app.view.lvChats.SetCurrentItem(loc[0])
+ if !app.event(evLocate) {
+ return
+ }
+}
diff --git a/internal/tui/fsm.go b/internal/tui/fsm.go
new file mode 100644
index 0000000..6724e19
--- /dev/null
+++ b/internal/tui/fsm.go
@@ -0,0 +1,145 @@
+package tui
+
+import (
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/looplab/fsm"
+)
+
+type machine struct {
+ app *App
+ fsm *fsm.FSM
+}
+
+const (
+ // events
+ evSelected = "selected"
+ evCancelled = "cancelled"
+ evConfirmed = "confirmed"
+ evDeleted = "deleted"
+ evFetched = "fetched"
+ evNothingToDo = "nothing_to_do"
+ evSearch = "search"
+ evLocate = "locate"
+
+ // states
+ stSelecting = "selecting"
+ stSearching = "searching"
+ stFetching = "fetching"
+ stConfirming = "confirming"
+ stDeleting = "deleting"
+ stNothing = "nothing"
+
+ // metadata
+ metaMessages = "messages"
+ metaChat = "chat"
+)
+
+func initFSM(app *App) *fsm.FSM {
+ m := machine{app: app}
+ sm := fsm.NewFSM(
+ stSelecting,
+ fsm.Events{
+ {Name: evSelected, Src: []string{stSelecting}, Dst: stFetching},
+ {Name: evFetched, Src: []string{stFetching}, Dst: stConfirming},
+ {Name: evNothingToDo, Src: []string{stFetching}, Dst: stNothing},
+ {Name: evConfirmed, Src: []string{stConfirming}, Dst: stDeleting},
+ {Name: evDeleted, Src: []string{stDeleting}, Dst: stSelecting},
+ // search
+ {Name: evSearch, Src: []string{stSelecting}, Dst: stSearching},
+ {Name: evLocate, Src: []string{stSearching}, Dst: stSelecting},
+ // cancel
+ {Name: evCancelled, Src: []string{stFetching, stConfirming, stNothing, stSearching}, Dst: stSelecting},
+ },
+ fsm.Callbacks{
+ m.enter("state"): func(e *fsm.Event) {
+ m.app.log.Debugf("*** transition: %q -> %q\n", e.Src, e.Dst)
+ m.app.pages.ShowPage(e.Dst)
+ },
+ // states
+ m.leave(stConfirming): m.hidePage,
+ m.leave(stNothing): m.hidePage,
+ m.leave(stSearching): m.hidePage,
+ m.leave(stDeleting): m.leaveDeleting,
+ // events
+ m.after(evCancelled): m.afterCancelled,
+ },
+ )
+ m.fsm = sm
+
+ return m.fsm
+}
+
+func (*machine) leave(state string) string {
+ return "leave_" + state
+}
+
+func (*machine) enter(state string) string {
+ return "enter_" + state
+}
+
+func (*machine) after(event string) string {
+ return "after_" + event
+}
+
+//
+// States
+//
+
+func (m *machine) hidePage(e *fsm.Event) {
+ m.app.pages.HidePage(e.Src)
+}
+
+func (m *machine) leaveDeleting(e *fsm.Event) {
+ m.cleanUp()
+ m.hidePage(e)
+}
+
+//
+// Events
+//
+
+func (m *machine) afterCancelled(*fsm.Event) {
+ // clear metadata
+ m.cleanUp()
+ m.app.logf("Operation cancelled")
+}
+
+func (m *machine) cleanUp() {
+ m.fsm.SetMetadata(metaChat, nil)
+ m.fsm.SetMetadata(metaMessages, nil)
+}
+
+// eventValue allows to get an event value at idx.
+func eventValue[T any](e *fsm.Event, idx int) (T, bool) {
+ var ret T
+ if len(e.Args)-1 < idx {
+ return ret, false
+ }
+ ret, ok := e.Args[idx].(T)
+ if !ok {
+ return ret, false
+ }
+ return ret, true
+}
+
+func metadata[T any](fsm *fsm.FSM, key string) (T, error) {
+ var ret T
+ val, ok := fsm.Metadata(key)
+ if !ok || val == nil {
+ return ret, fmt.Errorf("value of type %T not present in metadata", ret)
+ }
+ ret, ok = val.(T)
+ if !ok {
+ return ret, fmt.Errorf("invalid type (metadata: %T, want %T)", val, ret)
+ }
+ return ret, nil
+}
+
+func visualise(m *fsm.FSM) {
+ if err := os.WriteFile("fsm.dot", []byte(fsm.Visualize(m)), 0666); err != nil {
+ log.Panicf("error writing fsm: %s", err)
+ }
+}
diff --git a/internal/tui/nothing.go b/internal/tui/nothing.go
new file mode 100644
index 0000000..b02e227
--- /dev/null
+++ b/internal/tui/nothing.go
@@ -0,0 +1,11 @@
+package tui
+
+func (app *App) initNothing() {
+ app.pages.AddPage(stNothing, app.view.mbNothing, false, false)
+ app.view.mbNothing.
+ SetDoneFunc(func(_ int, _ string) {
+ app.cancel()
+ }).
+ SetText("There are no messages to delete").
+ AddButtons([]string{btnOK})
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..e32bdb5
--- /dev/null
+++ b/main.go
@@ -0,0 +1,246 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "sort"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/fatih/color"
+ "github.com/gotd/td/session"
+ "github.com/gotd/td/telegram"
+ "github.com/joho/godotenv"
+ "github.com/rusq/dlog"
+ "github.com/rusq/osenv/v2"
+ "github.com/rusq/tracer"
+ "github.com/schollz/progressbar/v3"
+
+ "github.com/rusq/wipemychat/internal/mtp"
+ "github.com/rusq/wipemychat/internal/mtp/authflow"
+ "github.com/rusq/wipemychat/internal/sys"
+ "github.com/rusq/wipemychat/internal/tui"
+)
+
+const cacheDirName = "tgmsg_revoker"
+
+const AppName = "Wipe My Chat for Telegram"
+
+var (
+ version = "dev"
+ builtOn = "just now"
+ gitCommit = ""
+ gitRef = ""
+
+ versionSig = fmt.Sprintf("%s %s (built %s)", AppName, version, builtOn)
+)
+
+var _ = godotenv.Load() // load environment variables from .env, if present
+
+type Params struct {
+ CacheDirName string
+ ApiID int
+ ApiHash string
+ Phone string
+ Reset bool
+
+ Version bool
+ Verbose bool
+ Trace string
+
+ cacheDir string
+}
+
+func main() {
+ p, err := parseCmdLine()
+ if err != nil {
+ dlog.Fatal(err)
+ }
+ if p.Version {
+ ver(os.Stdout)
+ return
+ }
+
+ dlog.SetDebug(p.Verbose)
+
+ if err := p.initCacheDir(cacheDirName); err != nil {
+ dlog.Fatalf("failed to create cache directory: %s", err)
+ }
+
+ dlog.SetDebug(p.Verbose)
+
+ if err := run(context.Background(), p); err != nil {
+ dlog.Fatal(err)
+ }
+}
+
+func parseCmdLine() (Params, error) {
+ var p = Params{CacheDirName: cacheDirName}
+ {
+ flag.IntVar(&p.ApiID, "api-id", osenv.Secret("APP_ID", 0), "Telegram API ID")
+ flag.StringVar(&p.ApiHash, "api-token", osenv.Secret("APP_HASH", ""), "Telegram API token")
+ flag.StringVar(&p.Phone, "phone", osenv.Value("PHONE", ""), "phone `number` in international format for authentication (optional)")
+ flag.BoolVar(&p.Reset, "reset", false, "reset authentication")
+
+ flag.BoolVar(&p.Version, "v", false, "print version and exit")
+ flag.BoolVar(&p.Verbose, "verbose", osenv.Value("DEBUG", "") != "", "verbose output")
+ flag.StringVar(&p.Trace, "trace", osenv.Value("TRACE_FILE", ""), "trace `filename`")
+
+ flag.Parse()
+ }
+ return p, nil
+}
+
+func (p *Params) initCacheDir(appName string) error {
+ cacheDir, err := os.UserCacheDir()
+ if err != nil {
+ return err
+ }
+ cacheDir = filepath.Join(cacheDir, appName)
+ if err := os.MkdirAll(cacheDir, 0700); err != nil {
+ return err
+ }
+ p.cacheDir = cacheDir
+ return nil
+}
+
+func unlink(path string) error {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
+func run(ctx context.Context, p Params) error {
+ if p.Trace != "" {
+ tr := tracer.New(p.Trace)
+ if err := tr.Start(); err != nil {
+ return err
+ }
+ defer tr.End()
+ }
+
+ header(os.Stdout)
+ fmt.Println()
+
+ sessStorage := session.FileStorage{Path: filepath.Join(p.cacheDir, "session.dat")}
+ apiCredsFile := filepath.Join(p.cacheDir, "telegram.dat")
+ if p.Reset {
+ if err := unlink(sessStorage.Path); err != nil {
+ return err
+ }
+ if err := unlink(apiCredsFile); err != nil {
+ return err
+ }
+ }
+
+ opts := telegram.Options{
+ SessionStorage: &sessStorage,
+ }
+
+ iface, err := sys.FindIface()
+ if err != nil {
+ return err
+ }
+ key, err := sys.IfaceMAC(iface)
+ if err != nil {
+ return err
+ }
+
+ cl, err := mtp.New(p.ApiID, p.ApiHash,
+ mtp.WithAuth(authflow.NewTermAuth(p.Phone)),
+ mtp.WithApiCredsFile(apiCredsFile, key),
+ mtp.WithMTPOptions(opts),
+ mtp.WithDebug(p.Verbose),
+ )
+ if err != nil {
+ return err
+ }
+
+ dlog.Println("Connecting to telegram . . .")
+ if err := cl.Start(ctx); err != nil {
+ return err
+ }
+ defer func() {
+ if err := cl.Stop(); err != nil {
+ dlog.Printf("stop error: %s", err)
+ }
+ }()
+
+ ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ done, finished := fakeProgress("Getting chats . . .", 0)
+ chats, err := cl.GetChats(ctx)
+ close(done)
+ <-finished
+
+ if err != nil {
+ return err
+ }
+ sort.Slice(chats, func(i, j int) bool {
+ return chats[i].GetTitle() < chats[j].GetTitle()
+ })
+ dlog.Printf("got %d chats", len(chats))
+
+ tva := tui.New(cl)
+ if err := tva.Run(ctx, chats); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// fakeProgress starts a fake spinner and returns a channel that must be closed
+// once the operation completes. interval is interval between iterations. If not
+// set, will default to 50ms.
+func fakeProgress(title string, interval time.Duration) (chan<- struct{}, <-chan struct{}) {
+ if interval == 0 {
+ interval = 50 * time.Millisecond
+ }
+ done := make(chan struct{})
+ finished := make(chan struct{})
+ go func() {
+ bar := progressbar.NewOptions(
+ -1,
+ progressbar.OptionSetDescription(title),
+ progressbar.OptionSetPredictTime(false),
+ progressbar.OptionSpinnerType(9),
+ )
+ t := time.NewTicker(interval)
+ defer t.Stop()
+
+ for {
+ select {
+ case <-done:
+ bar.Finish()
+ fmt.Println()
+ close(finished)
+ return
+ case <-t.C:
+ bar.Add(1)
+ }
+ }
+ }()
+ return done, finished
+}
+
+func header(w io.Writer) {
+ fmt.Fprintf(w,
+ "%s\n%s\n%s\n", versionSig, strings.Repeat("-", len(versionSig)),
+ color.New(color.Italic).Sprint("In loving memory of V. Gorban, 1967-2022."),
+ )
+}
+
+func ver(w io.Writer) {
+ header(w)
+ if gitCommit != "" {
+ fmt.Fprintf(w, "commit: %s ref: %s\n", gitCommit, gitRef)
+ }
+}