├── .github ├── preview.png └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd ├── application.go ├── guilds_tree.go ├── message_input.go ├── messages_text.go ├── root.go └── state.go ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── internal ├── config │ ├── border.go │ ├── config.go │ ├── config.toml │ ├── keys.go │ └── theme.go ├── consts │ ├── consts.go │ ├── consts_darwin.go │ ├── consts_linux.go │ └── consts_windows.go ├── logger │ └── logger.go ├── login │ └── form.go ├── markdown │ └── renderer.go ├── notifications │ ├── desktop_toast.go │ ├── desktop_toast_darwin.go │ ├── notifications.go │ └── renderer.go └── ui │ └── util.go ├── main.go └── nix ├── module-hm.nix └── package.nix /.github/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayn2op/discordo/6f77211ffef346193772ce9240d30cf9415ba769/.github/preview.png -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | # https://docs.github.com/en/actions/using-github-hosted-runners 10 | os: [ubuntu-latest, windows-latest, macos-latest, macos-13] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: actions/setup-go@v5 16 | with: 17 | go-version: stable 18 | 19 | - name: Build 20 | run: go build -ldflags "-s -w" . 21 | 22 | - uses: actions/upload-artifact@v4 23 | if: ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} 24 | with: 25 | name: discordo_${{ runner.os }}_${{ runner.arch }} 26 | path: | 27 | discordo 28 | discordo.exe 29 | 30 | - name: Send repository dispatch 31 | if: ${{ runner.os == 'Windows' && github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} 32 | env: 33 | GH_TOKEN: ${{ secrets.PAT }} 34 | run: | 35 | gh api --method POST -H "Accept: application/vnd.github+json" -f "event_type=discordo-ci-completed" /repos/vvirtues/bucket/dispatches 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | discordo* 2 | 3 | .golangci* 4 | 5 | # Visual Studio Code 6 | .vscode/ 7 | 8 | # Nix stuff 9 | result 10 | 11 | # direnv (devs should have this file locally) 12 | .direnv/ 13 | .envrc 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discordo · [![discord](https://img.shields.io/discord/1297292231299956788?color=5865F2&logo=discord&logoColor=white)](https://discord.com/invite/VzF9UFn2aB) [![ci](https://github.com/ayn2op/discordo/actions/workflows/ci.yml/badge.svg)](https://github.com/ayn2op/discordo/actions/workflows/ci.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/ayn2op/discordo)](https://goreportcard.com/report/github.com/ayn2op/discordo) [![license](https://img.shields.io/github/license/ayn2op/discordo?logo=github)](https://github.com/ayn2op/discordo/blob/master/LICENSE) 2 | 3 | Discordo is a lightweight, secure, and feature-rich Discord terminal client. Heavily work-in-progress, expect breaking changes. 4 | 5 | ![Preview](.github/preview.png) 6 | 7 | ## Features 8 | 9 | - Lightweight 10 | - Configurable 11 | - Mouse & clipboard support 12 | - Notifications 13 | - 2-Factor authentication 14 | - Discord-flavored markdown 15 | 16 | ## Installation 17 | 18 | ### Prebuilt binaries 19 | 20 | You can download and install a [prebuilt binary here](https://nightly.link/ayn2op/discordo/workflows/ci/main) for Windows, macOS, or Linux. 21 | 22 | ### Package managers 23 | 24 | - Arch Linux: `yay -S discordo-git` 25 | - FreeBSD: `pkg install discordo` or via the ports system `make -C /usr/ports/net-im/discordo install clean`. 26 | - Nix (NixOS, home-manager) 27 | - Downstream nixpkgs installation: Add `pkgs.discordo` to `environment.systemPackages` or `home.packages`. 28 | 29 | - Upstream flake installation: Add `inputs.discordo.url = "github:ayn2op/discordo"`. Install using `inputs.discordo.homeModules.default` (`.enable, .package, .settings TOML`). 30 | - Windows (Scoop): 31 | 32 | ```sh 33 | scoop bucket add vvxrtues https://github.com/vvirtues/bucket 34 | scoop install discordo 35 | ``` 36 | 37 | ### Building from source 38 | 39 | ```bash 40 | git clone https://github.com/ayn2op/discordo 41 | cd discordo 42 | go build . 43 | ``` 44 | 45 | ### Linux clipboard support 46 | 47 | - `xclip` or `xsel` for X11 (`apt install xclip`) 48 | - `wl-clipboard` for Wayland (`apt install wl-clipboard`) 49 | 50 | ## Usage 51 | 52 | 1. Run the `discordo` executable with no arguments. 53 | 54 | > If you are logging in using an authentication token, provide the `token` command-line flag to the executable (eg: `--token "OTI2MDU5NTQxNDE2Nzc5ODA2.Yc2KKA.2iZ-5JxgxG-9Ub8GHzBSn-NJjNg"`). The token is stored securely in the default OS-specific keyring. 55 | 56 | 2. Enter your email and password and click on the "Login" button to continue. 57 | 58 | ## Configuration 59 | 60 | The configuration file allows you to configure and customize the behavior, keybindings, and theme of the application. 61 | 62 | - Unix: `$XDG_CONFIG_HOME/discordo/config.toml` or `$HOME/.config/discordo/config.toml` 63 | - Darwin: `$HOME/Library/Application Support/discordo/config.toml` 64 | - Windows: `%AppData%/discordo/config.toml` 65 | 66 | [The default configuration can be found here](./internal/config/config.toml). 67 | 68 | ## FAQ 69 | 70 | ### Manually adding token to keyring 71 | 72 | Do this if you get the error: 73 | 74 | > failed to get token from keyring: secret not found in keyring 75 | 76 | #### MacOS 77 | 78 | Run the following command in a terminal window with `sudo` to create the `token` entry. 79 | 80 | ```sh 81 | security add-generic-password -s discordo -a token -w "DISCORD TOKEN HERE" 82 | ``` 83 | 84 | #### Linux 85 | 86 | 1. Start the keyring daemon. 87 | 88 | ```sh 89 | eval $(gnome-keyring-daemon --start) 90 | export $(gnome-keyring-daemon --start) 91 | ``` 92 | 93 | 2. Create the `login` keyring if it does not exist already. See [GNOME/Keyring](https://wiki.archlinux.org/title/GNOME/Keyring) for more information. 94 | 95 | 3. Run the following command to create the `token` entry. 96 | 97 | ```sh 98 | secret-tool store --label="DISCORD TOKEN HERE" service discordo username token 99 | ``` 100 | 101 | 4. When it prompts for the password, paste your token, and hit enter to confirm. 102 | 103 | > [!IMPORTANT] 104 | > Automated user accounts or "self-bots" are against Discord's Terms of Service. I am not responsible for any loss caused by using "self-bots" or Discordo. 105 | -------------------------------------------------------------------------------- /cmd/application.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/ayn2op/discordo/internal/config" 7 | "github.com/ayn2op/discordo/internal/consts" 8 | "github.com/ayn2op/discordo/internal/login" 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | "github.com/zalando/go-keyring" 12 | ) 13 | 14 | type application struct { 15 | *tview.Application 16 | 17 | cfg *config.Config 18 | 19 | pages *tview.Pages 20 | flex *tview.Flex 21 | guildsTree *guildsTree 22 | messagesText *messagesText 23 | messageInput *messageInput 24 | } 25 | 26 | func newApp(cfg *config.Config) *application { 27 | app := tview.NewApplication() 28 | a := &application{ 29 | Application: app, 30 | 31 | cfg: cfg, 32 | 33 | pages: tview.NewPages(), 34 | flex: tview.NewFlex(), 35 | guildsTree: newGuildsTree(app, cfg), 36 | messagesText: newMessagesText(app, cfg), 37 | messageInput: newMessageInput(app, cfg), 38 | } 39 | 40 | a.EnableMouse(cfg.Mouse) 41 | a.SetInputCapture(a.onInputCapture) 42 | a.flex.SetInputCapture(a.onFlexInputCapture) 43 | return a 44 | } 45 | 46 | func (app *application) show(token string) error { 47 | if token == "" { 48 | loginForm := login.NewForm(app.cfg, app.Application, func(token string) { 49 | if err := app.show(token); err != nil { 50 | slog.Error("failed to show app", "err", err) 51 | return 52 | } 53 | }) 54 | 55 | app.SetRoot(loginForm, true) 56 | } else { 57 | if err := openState(token); err != nil { 58 | return err 59 | } 60 | 61 | app.init() 62 | app.SetRoot(app.pages, true) 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (app *application) run(token string) error { 69 | if err := app.show(token); err != nil { 70 | return err 71 | } 72 | 73 | return app.Run() 74 | } 75 | 76 | func (a *application) clearPages() { 77 | for _, name := range a.pages.GetPageNames(false) { 78 | a.pages.RemovePage(name) 79 | } 80 | } 81 | 82 | func (a *application) init() { 83 | a.clearPages() 84 | a.flex.Clear() 85 | 86 | right := tview.NewFlex() 87 | right.SetDirection(tview.FlexRow) 88 | right.AddItem(a.messagesText, 0, 1, false) 89 | right.AddItem(a.messageInput, 3, 1, false) 90 | // The guilds tree is always focused first at start-up. 91 | a.flex.AddItem(a.guildsTree, 0, 1, true) 92 | a.flex.AddItem(right, 0, 4, false) 93 | a.pages.AddAndSwitchToPage("flex", a.flex, true) 94 | } 95 | 96 | func (app *application) onInputCapture(event *tcell.EventKey) *tcell.EventKey { 97 | switch event.Name() { 98 | case app.cfg.Keys.Quit: 99 | if discordState != nil { 100 | if err := discordState.Close(); err != nil { 101 | slog.Error("failed to close the session", "err", err) 102 | } 103 | } 104 | 105 | app.Stop() 106 | case "Ctrl+C": 107 | // https://github.com/rivo/tview/blob/a64fc48d7654432f71922c8b908280cdb525805c/application.go#L153 108 | return tcell.NewEventKey(tcell.KeyCtrlC, 0, tcell.ModNone) 109 | } 110 | 111 | return event 112 | } 113 | 114 | func (app *application) onFlexInputCapture(event *tcell.EventKey) *tcell.EventKey { 115 | switch event.Name() { 116 | case app.cfg.Keys.FocusGuildsTree: 117 | app.SetFocus(app.guildsTree) 118 | return nil 119 | case app.cfg.Keys.FocusMessagesText: 120 | app.SetFocus(app.messagesText) 121 | return nil 122 | case app.cfg.Keys.FocusMessageInput: 123 | app.SetFocus(app.messageInput) 124 | return nil 125 | case app.cfg.Keys.Logout: 126 | app.Stop() 127 | 128 | if err := keyring.Delete(consts.Name, "token"); err != nil { 129 | slog.Error("failed to delete token from keyring", "err", err) 130 | return nil 131 | } 132 | 133 | return nil 134 | case app.cfg.Keys.ToggleGuildsTree: 135 | // The guilds tree is visible if the numbers of items is two. 136 | if app.flex.GetItemCount() == 2 { 137 | app.flex.RemoveItem(app.guildsTree) 138 | 139 | if app.guildsTree.HasFocus() { 140 | app.SetFocus(app.flex) 141 | } 142 | } else { 143 | app.init() 144 | app.SetFocus(app.guildsTree) 145 | } 146 | 147 | return nil 148 | } 149 | 150 | return event 151 | } 152 | -------------------------------------------------------------------------------- /cmd/guilds_tree.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/atotto/clipboard" 10 | "github.com/ayn2op/discordo/internal/config" 11 | "github.com/ayn2op/discordo/internal/ui" 12 | "github.com/diamondburned/arikawa/v3/discord" 13 | "github.com/diamondburned/arikawa/v3/gateway" 14 | "github.com/gdamore/tcell/v2" 15 | "github.com/rivo/tview" 16 | ) 17 | 18 | type guildsTree struct { 19 | *tview.TreeView 20 | cfg *config.Config 21 | app *tview.Application 22 | selectedChannelID discord.ChannelID 23 | } 24 | 25 | func newGuildsTree(app *tview.Application, cfg *config.Config) *guildsTree { 26 | gt := &guildsTree{ 27 | TreeView: tview.NewTreeView(), 28 | cfg: cfg, 29 | app: app, 30 | } 31 | 32 | gt.Box = ui.NewConfiguredBox(gt.Box, &cfg.Theme) 33 | 34 | gt. 35 | SetRoot(tview.NewTreeNode("")). 36 | SetTopLevel(1). 37 | SetGraphics(cfg.Theme.GuildsTree.Graphics). 38 | SetSelectedFunc(gt.onSelected). 39 | SetTitle("Guilds"). 40 | SetInputCapture(gt.onInputCapture) 41 | 42 | return gt 43 | } 44 | 45 | func (gt *guildsTree) createFolderNode(folder gateway.GuildFolder) { 46 | var name string 47 | if folder.Name == "" { 48 | name = "Folder" 49 | } else { 50 | name = fmt.Sprintf("[%s]%s[-]", folder.Color.String(), folder.Name) 51 | } 52 | 53 | root := gt.GetRoot() 54 | folderNode := tview.NewTreeNode(name) 55 | folderNode.SetExpanded(gt.cfg.Theme.GuildsTree.AutoExpandFolders) 56 | root.AddChild(folderNode) 57 | 58 | for _, gID := range folder.GuildIDs { 59 | g, err := discordState.Cabinet.Guild(gID) 60 | if err != nil { 61 | slog.Info("failed to get guild from state", "guild_id", gID, "err", err) 62 | continue 63 | } 64 | 65 | gt.createGuildNode(folderNode, *g) 66 | } 67 | } 68 | 69 | func (gt *guildsTree) createGuildNode(n *tview.TreeNode, g discord.Guild) { 70 | guildNode := tview.NewTreeNode(g.Name) 71 | guildNode.SetReference(g.ID) 72 | guildNode.SetColor(tcell.GetColor(gt.cfg.Theme.GuildsTree.GuildColor)) 73 | n.AddChild(guildNode) 74 | } 75 | 76 | func (gt *guildsTree) channelToString(c discord.Channel) string { 77 | switch c.Type { 78 | case discord.DirectMessage, discord.GroupDM: 79 | if c.Name != "" { 80 | return c.Name 81 | } 82 | 83 | recipients := make([]string, len(c.DMRecipients)) 84 | for i, r := range c.DMRecipients { 85 | recipients[i] = r.DisplayOrUsername() 86 | } 87 | 88 | return strings.Join(recipients, ", ") 89 | 90 | case discord.GuildText: 91 | return "#" + c.Name 92 | case discord.GuildVoice, discord.GuildStageVoice: 93 | return "v-" + c.Name 94 | case discord.GuildAnnouncement: 95 | return "a-" + c.Name 96 | case discord.GuildStore: 97 | return "s-" + c.Name 98 | case discord.GuildForum: 99 | return "f-" + c.Name 100 | default: 101 | return c.Name 102 | } 103 | } 104 | 105 | func (gt *guildsTree) createChannelNode(n *tview.TreeNode, c discord.Channel) *tview.TreeNode { 106 | if c.Type != discord.DirectMessage && c.Type != discord.GroupDM { 107 | ps, err := discordState.Permissions(c.ID, discordState.Ready().User.ID) 108 | if err != nil { 109 | slog.Error("failed to get permissions", "err", err, "channel_id", c.ID) 110 | return nil 111 | } 112 | 113 | if !ps.Has(discord.PermissionViewChannel) { 114 | return nil 115 | } 116 | } 117 | 118 | channelNode := tview.NewTreeNode(gt.channelToString(c)) 119 | channelNode.SetReference(c.ID) 120 | channelNode.SetColor(tcell.GetColor(gt.cfg.Theme.GuildsTree.ChannelColor)) 121 | n.AddChild(channelNode) 122 | return channelNode 123 | } 124 | 125 | func (gt *guildsTree) createChannelNodes(n *tview.TreeNode, cs []discord.Channel) { 126 | var orphanChs []discord.Channel 127 | for _, ch := range cs { 128 | if ch.Type != discord.GuildCategory && !ch.ParentID.IsValid() { 129 | orphanChs = append(orphanChs, ch) 130 | } 131 | } 132 | 133 | for _, c := range orphanChs { 134 | gt.createChannelNode(n, c) 135 | } 136 | 137 | PARENT_CHANNELS: 138 | for _, c := range cs { 139 | if c.Type == discord.GuildCategory { 140 | for _, nested := range cs { 141 | if nested.ParentID == c.ID { 142 | gt.createChannelNode(n, c) 143 | continue PARENT_CHANNELS 144 | } 145 | } 146 | } 147 | } 148 | 149 | for _, c := range cs { 150 | if c.ParentID.IsValid() { 151 | var parent *tview.TreeNode 152 | n.Walk(func(node, _ *tview.TreeNode) bool { 153 | if node.GetReference() == c.ParentID { 154 | parent = node 155 | return false 156 | } 157 | 158 | return true 159 | }) 160 | 161 | if parent != nil { 162 | gt.createChannelNode(parent, c) 163 | } 164 | } 165 | } 166 | } 167 | 168 | func (gt *guildsTree) onSelected(n *tview.TreeNode) { 169 | gt.selectedChannelID = 0 170 | 171 | app.messagesText.reset() 172 | app.messageInput.reset() 173 | 174 | if len(n.GetChildren()) != 0 { 175 | n.SetExpanded(!n.IsExpanded()) 176 | return 177 | } 178 | 179 | switch ref := n.GetReference().(type) { 180 | case discord.GuildID: 181 | go discordState.MemberState.Subscribe(ref) 182 | 183 | cs, err := discordState.Cabinet.Channels(ref) 184 | if err != nil { 185 | slog.Error("failed to get channels", "err", err, "guild_id", ref) 186 | return 187 | } 188 | 189 | sort.Slice(cs, func(i, j int) bool { 190 | return cs[i].Position < cs[j].Position 191 | }) 192 | 193 | gt.createChannelNodes(n, cs) 194 | case discord.ChannelID: 195 | c, err := discordState.Cabinet.Channel(ref) 196 | if err != nil { 197 | slog.Error("failed to get channel", "channel_id", ref) 198 | return 199 | } 200 | 201 | app.messagesText.drawMsgs(c.ID) 202 | app.messagesText.ScrollToEnd() 203 | app.messagesText.SetTitle(gt.channelToString(*c)) 204 | 205 | gt.selectedChannelID = c.ID 206 | gt.app.SetFocus(app.messageInput) 207 | case nil: // Direct messages 208 | cs, err := discordState.PrivateChannels() 209 | if err != nil { 210 | slog.Error("failed to get private channels", "err", err) 211 | return 212 | } 213 | 214 | sort.Slice(cs, func(a, b int) bool { 215 | msgID := func(ch discord.Channel) discord.MessageID { 216 | if ch.LastMessageID.IsValid() { 217 | return ch.LastMessageID 218 | } 219 | return discord.MessageID(ch.ID) 220 | } 221 | return msgID(cs[a]) > msgID(cs[b]) 222 | }) 223 | 224 | for _, c := range cs { 225 | gt.createChannelNode(n, c) 226 | } 227 | } 228 | } 229 | 230 | func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) { 231 | gt. 232 | GetRoot(). 233 | Walk(func(n, parent *tview.TreeNode) bool { 234 | if n == node && parent.GetLevel() != 0 { 235 | parent.Collapse() 236 | gt.SetCurrentNode(parent) 237 | return false 238 | } 239 | 240 | return true 241 | }) 242 | } 243 | 244 | func (gt *guildsTree) onInputCapture(event *tcell.EventKey) *tcell.EventKey { 245 | switch event.Name() { 246 | case gt.cfg.Keys.GuildsTree.CollapseParentNode: 247 | gt.collapseParentNode(gt.GetCurrentNode()) 248 | return nil 249 | case gt.cfg.Keys.GuildsTree.MoveToParentNode: 250 | return tcell.NewEventKey(tcell.KeyRune, 'K', tcell.ModNone) 251 | 252 | case gt.cfg.Keys.GuildsTree.SelectPrevious: 253 | return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) 254 | case gt.cfg.Keys.GuildsTree.SelectNext: 255 | return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) 256 | case gt.cfg.Keys.GuildsTree.SelectFirst: 257 | gt.Move(gt.GetRowCount() * -1) 258 | // return tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone) 259 | case gt.cfg.Keys.GuildsTree.SelectLast: 260 | gt.Move(gt.GetRowCount()) 261 | // return tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone) 262 | 263 | case gt.cfg.Keys.GuildsTree.SelectCurrent: 264 | return tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone) 265 | 266 | case gt.cfg.Keys.GuildsTree.YankID: 267 | node := gt.GetCurrentNode() 268 | if node == nil { 269 | return nil 270 | } 271 | 272 | // Reference of a tree node in the guilds tree is its ID. 273 | // discord.Snowflake (discord.GuildID and discord.ChannelID) have the String method. 274 | if id, ok := node.GetReference().(fmt.Stringer); ok { 275 | go clipboard.WriteAll(id.String()) 276 | } 277 | } 278 | 279 | return nil 280 | } 281 | -------------------------------------------------------------------------------- /cmd/message_input.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/atotto/clipboard" 10 | "github.com/ayn2op/discordo/internal/config" 11 | "github.com/ayn2op/discordo/internal/consts" 12 | "github.com/ayn2op/discordo/internal/ui" 13 | "github.com/diamondburned/arikawa/v3/api" 14 | "github.com/diamondburned/arikawa/v3/discord" 15 | "github.com/diamondburned/arikawa/v3/utils/json/option" 16 | "github.com/gdamore/tcell/v2" 17 | "github.com/rivo/tview" 18 | ) 19 | 20 | const tmpFilePattern = consts.Name + "_*.md" 21 | 22 | type messageInput struct { 23 | *tview.TextArea 24 | cfg *config.Config 25 | app *tview.Application 26 | replyMessageID discord.MessageID 27 | } 28 | 29 | func newMessageInput(app *tview.Application, cfg *config.Config) *messageInput { 30 | mi := &messageInput{ 31 | TextArea: tview.NewTextArea(), 32 | cfg: cfg, 33 | app: app, 34 | } 35 | 36 | mi.Box = ui.NewConfiguredBox(mi.Box, &cfg.Theme) 37 | 38 | mi. 39 | SetTextStyle(tcell.StyleDefault.Background(tcell.GetColor(cfg.Theme.BackgroundColor))). 40 | SetClipboard(func(s string) { 41 | _ = clipboard.WriteAll(s) 42 | }, func() string { 43 | text, _ := clipboard.ReadAll() 44 | return text 45 | }). 46 | SetInputCapture(mi.onInputCapture) 47 | 48 | return mi 49 | } 50 | 51 | func (mi *messageInput) reset() { 52 | mi.replyMessageID = 0 53 | mi.SetTitle("") 54 | mi.SetText("", true) 55 | } 56 | 57 | func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { 58 | switch event.Name() { 59 | case mi.cfg.Keys.MessageInput.Send: 60 | mi.send() 61 | return nil 62 | case mi.cfg.Keys.MessageInput.Editor: 63 | mi.editor() 64 | return nil 65 | case mi.cfg.Keys.MessageInput.Cancel: 66 | mi.reset() 67 | return nil 68 | } 69 | 70 | return event 71 | } 72 | 73 | func (mi *messageInput) send() { 74 | if !app.guildsTree.selectedChannelID.IsValid() { 75 | return 76 | } 77 | 78 | text := strings.TrimSpace(mi.GetText()) 79 | if text == "" { 80 | return 81 | } 82 | 83 | data := api.SendMessageData{ 84 | Content: text, 85 | } 86 | if mi.replyMessageID != 0 { 87 | data.Reference = &discord.MessageReference{MessageID: mi.replyMessageID} 88 | data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False} 89 | 90 | if strings.HasPrefix(mi.GetTitle(), "[@]") { 91 | data.AllowedMentions.RepliedUser = option.True 92 | } 93 | } 94 | 95 | go func() { 96 | if _, err := discordState.SendMessageComplex(app.guildsTree.selectedChannelID, data); err != nil { 97 | slog.Error("failed to send message in channel", "channel_id", app.guildsTree.selectedChannelID, "err", err) 98 | } 99 | }() 100 | 101 | mi.replyMessageID = 0 102 | mi.reset() 103 | 104 | app.messagesText.Highlight() 105 | app.messagesText.ScrollToEnd() 106 | } 107 | 108 | func (mi *messageInput) editor() { 109 | editor := mi.cfg.Editor 110 | if editor == "" { 111 | return 112 | } 113 | 114 | file, err := os.CreateTemp("", tmpFilePattern) 115 | if err != nil { 116 | slog.Error("failed to create tmp file", "err", err) 117 | return 118 | } 119 | defer file.Close() 120 | defer os.Remove(file.Name()) 121 | 122 | _, _ = file.WriteString(mi.GetText()) 123 | 124 | cmd := exec.Command(editor, file.Name()) 125 | cmd.Stdin = os.Stdin 126 | cmd.Stdout = os.Stdout 127 | cmd.Stderr = os.Stderr 128 | 129 | mi.app.Suspend(func() { 130 | err := cmd.Run() 131 | if err != nil { 132 | slog.Error("failed to run command", "args", cmd.Args, "err", err) 133 | return 134 | } 135 | }) 136 | 137 | msg, err := os.ReadFile(file.Name()) 138 | if err != nil { 139 | slog.Error("failed to read tmp file", "name", file.Name(), "err", err) 140 | return 141 | } 142 | 143 | mi.SetText(strings.TrimSpace(string(msg)), true) 144 | } 145 | -------------------------------------------------------------------------------- /cmd/messages_text.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "slices" 10 | "sync" 11 | "time" 12 | 13 | "github.com/atotto/clipboard" 14 | "github.com/ayn2op/discordo/internal/config" 15 | "github.com/ayn2op/discordo/internal/markdown" 16 | "github.com/ayn2op/discordo/internal/ui" 17 | "github.com/diamondburned/arikawa/v3/discord" 18 | "github.com/diamondburned/arikawa/v3/gateway" 19 | "github.com/diamondburned/ningen/v3/discordmd" 20 | "github.com/gdamore/tcell/v2" 21 | "github.com/rivo/tview" 22 | "github.com/skratchdot/open-golang/open" 23 | "github.com/yuin/goldmark/ast" 24 | "github.com/yuin/goldmark/parser" 25 | "github.com/yuin/goldmark/renderer" 26 | "github.com/yuin/goldmark/text" 27 | ) 28 | 29 | type messagesText struct { 30 | *tview.TextView 31 | cfg *config.Config 32 | app *tview.Application 33 | selectedMessageID discord.MessageID 34 | 35 | fetchingMembers struct { 36 | mu sync.Mutex 37 | value bool 38 | done chan struct{} 39 | } 40 | } 41 | 42 | func newMessagesText(app *tview.Application, cfg *config.Config) *messagesText { 43 | mt := &messagesText{ 44 | TextView: tview.NewTextView(), 45 | cfg: cfg, 46 | app: app, 47 | } 48 | 49 | mt.Box = ui.NewConfiguredBox(mt.Box, &cfg.Theme) 50 | 51 | t := cfg.Theme 52 | mt. 53 | SetDynamicColors(true). 54 | SetRegions(true). 55 | SetWordWrap(true). 56 | ScrollToEnd(). 57 | SetTextColor(tcell.GetColor(t.MessagesText.ContentColor)). 58 | SetHighlightedFunc(mt.onHighlighted). 59 | SetChangedFunc(func() { 60 | app.Draw() 61 | }). 62 | SetTitle("Messages"). 63 | SetInputCapture(mt.onInputCapture) 64 | 65 | markdown.DefaultRenderer.AddOptions( 66 | renderer.WithOption("emojiColor", t.MessagesText.EmojiColor), 67 | renderer.WithOption("linkColor", t.MessagesText.LinkColor), 68 | renderer.WithOption("showNicknames", t.MessagesText.ShowNicknames), 69 | ) 70 | 71 | return mt 72 | } 73 | 74 | func (mt *messagesText) drawMsgs(cID discord.ChannelID) { 75 | ms, err := discordState.Messages(cID, uint(mt.cfg.MessagesLimit)) 76 | if err != nil { 77 | slog.Error("failed to get messages", "err", err, "channel_id", cID) 78 | return 79 | } 80 | 81 | if app.cfg.Theme.MessagesText.ShowNicknames || app.cfg.Theme.MessagesText.ShowUsernameColors { 82 | if ch, _ := discordState.Cabinet.Channel(cID); ch.GuildID.IsValid() { 83 | mt.requestGuildMembers(ch.GuildID, ms) 84 | } 85 | } 86 | 87 | for _, m := range slices.Backward(ms) { 88 | app.messagesText.createMsg(m) 89 | } 90 | } 91 | 92 | func (mt *messagesText) reset() { 93 | mt.selectedMessageID = 0 94 | app.messageInput.replyMessageID = 0 95 | 96 | mt.SetTitle("") 97 | mt.Clear() 98 | mt.Highlight() 99 | } 100 | 101 | // Region tags are square brackets that contain a region ID in double quotes 102 | // https://pkg.go.dev/github.com/rivo/tview#hdr-Regions_and_Highlights 103 | func (mt *messagesText) startRegion(msgID discord.MessageID) { 104 | fmt.Fprintf(mt, `["%s"]`, msgID) 105 | } 106 | 107 | // Tags with no region ID ([""]) don't start new regions. They can therefore be used to mark the end of a region. 108 | func (mt *messagesText) endRegion() { 109 | fmt.Fprint(mt, `[""]`) 110 | } 111 | 112 | func (mt *messagesText) createMsg(msg discord.Message) { 113 | mt.startRegion(msg.ID) 114 | defer mt.endRegion() 115 | 116 | if mt.cfg.HideBlockedUsers { 117 | isBlocked := discordState.UserIsBlocked(msg.Author.ID) 118 | if isBlocked { 119 | fmt.Fprintln(mt, "[:red:b]Blocked message[:-:-]") 120 | return 121 | } 122 | } 123 | 124 | // reset 125 | io.WriteString(mt, "[-:-:-]") 126 | 127 | switch msg.Type { 128 | case discord.DefaultMessage: 129 | if msg.Reference != nil && msg.Reference.Type == discord.MessageReferenceTypeForward { 130 | mt.createForwardedMsg(msg) 131 | } else { 132 | mt.createDefaultMsg(msg) 133 | } 134 | case discord.InlinedReplyMessage: 135 | mt.createReplyMsg(msg) 136 | 137 | case discord.ChannelPinnedMessage: 138 | fmt.Fprint(mt, "["+mt.cfg.Theme.MessagesText.ContentColor+"]"+msg.Author.Username+" pinned a message"+"[-:-:-]") 139 | 140 | default: 141 | mt.drawTimestamps(msg.Timestamp) 142 | mt.drawAuthor(msg) 143 | } 144 | 145 | fmt.Fprintln(mt) 146 | } 147 | 148 | func (mt *messagesText) formatTimestamp(ts discord.Timestamp) string { 149 | return ts.Time().In(time.Local).Format(mt.cfg.Timestamps.Format) 150 | } 151 | 152 | func (mt *messagesText) drawTimestamps(ts discord.Timestamp) { 153 | fmt.Fprintf(mt, "[::d]%s[::D] ", mt.formatTimestamp(ts)) 154 | } 155 | 156 | func (mt *messagesText) drawAuthor(msg discord.Message) { 157 | name := mt.authorName(msg.Author, msg.GuildID) 158 | color := mt.authorColor(msg.Author, msg.GuildID) 159 | fmt.Fprintf(mt, "[%s]%s[-] ", color, name) 160 | } 161 | 162 | func (mt *messagesText) drawContent(msg discord.Message) { 163 | c := []byte(tview.Escape(msg.Content)) 164 | ast := discordmd.ParseWithMessage(c, *discordState.Cabinet, &msg, false) 165 | if app.cfg.Markdown { 166 | markdown.DefaultRenderer.Render(mt, c, ast) 167 | } else { 168 | mt.Write(c) // write the content as is 169 | } 170 | } 171 | 172 | func (mt *messagesText) drawSnapshotContent(msg discord.MessageSnapshotMessage) { 173 | c := []byte(tview.Escape(msg.Content)) 174 | // discordmd doesn't support MessageSnapshotMessage, so we just use write it as is. todo? 175 | mt.Write(c) 176 | } 177 | 178 | func (mt *messagesText) createDefaultMsg(msg discord.Message) { 179 | if mt.cfg.Timestamps.Enabled { 180 | mt.drawTimestamps(msg.Timestamp) 181 | } 182 | 183 | mt.drawAuthor(msg) 184 | mt.drawContent(msg) 185 | 186 | if msg.EditedTimestamp.IsValid() { 187 | io.WriteString(mt, " [::d](edited)[::D]") 188 | } 189 | 190 | for _, a := range msg.Attachments { 191 | fmt.Fprintln(mt) 192 | if mt.cfg.ShowAttachmentLinks { 193 | fmt.Fprintf(mt, "[%s][%s]:\n%s[-]", mt.cfg.Theme.MessagesText.AttachmentColor, a.Filename, a.URL) 194 | } else { 195 | fmt.Fprintf(mt, "[%s][%s][-]", mt.cfg.Theme.MessagesText.AttachmentColor, a.Filename) 196 | } 197 | } 198 | } 199 | 200 | func (mt *messagesText) createReplyMsg(msg discord.Message) { 201 | // reply 202 | fmt.Fprintf(mt, "[::d]%s ", mt.cfg.Theme.MessagesText.ReplyIndicator) 203 | if refMsg := msg.ReferencedMessage; refMsg != nil { 204 | refMsg.GuildID = msg.GuildID 205 | mt.drawAuthor(*refMsg) 206 | mt.drawContent(*refMsg) 207 | } 208 | 209 | io.WriteString(mt, tview.NewLine) 210 | // main 211 | mt.createDefaultMsg(msg) 212 | } 213 | 214 | func (mt *messagesText) authorName(user discord.User, gID discord.GuildID) string { 215 | name := user.DisplayOrUsername() 216 | if app.cfg.Theme.MessagesText.ShowNicknames && gID.IsValid() { 217 | // Use guild nickname if present 218 | if member, _ := discordState.Cabinet.Member(gID, user.ID); member != nil && member.Nick != "" { 219 | name = member.Nick 220 | } 221 | } 222 | 223 | return name 224 | } 225 | 226 | func (mt *messagesText) createForwardedMsg(msg discord.Message) { 227 | mt.drawTimestamps(msg.Timestamp) 228 | mt.drawAuthor(msg) 229 | fmt.Fprintf(mt, "[::d]%s [::-]", mt.cfg.Theme.MessagesText.ForwardedIndicator) 230 | mt.drawSnapshotContent(msg.MessageSnapshots[0].Message) 231 | fmt.Fprintf(mt, " [::d](%s)[-:-:-] ", mt.formatTimestamp(msg.MessageSnapshots[0].Message.Timestamp)) 232 | } 233 | 234 | func (mt *messagesText) authorColor(user discord.User, gID discord.GuildID) string { 235 | color := mt.cfg.Theme.MessagesText.AuthorColor 236 | if app.cfg.Theme.MessagesText.ShowUsernameColors && gID.IsValid() { 237 | // Use color from highest role in guild 238 | if c, ok := discordState.MemberColor(gID, user.ID); ok { 239 | color = c.String() 240 | } 241 | } 242 | 243 | return color 244 | } 245 | 246 | func (mt *messagesText) selectedMsg() (*discord.Message, error) { 247 | if !mt.selectedMessageID.IsValid() { 248 | return nil, errors.New("no message is currently selected") 249 | } 250 | 251 | msg, err := discordState.Cabinet.Message(app.guildsTree.selectedChannelID, mt.selectedMessageID) 252 | if err != nil { 253 | return nil, fmt.Errorf("could not retrieve selected message: %w", err) 254 | } 255 | 256 | return msg, nil 257 | } 258 | 259 | func (mt *messagesText) selectedMsgIndex() (int, error) { 260 | ms, err := discordState.Cabinet.Messages(app.guildsTree.selectedChannelID) 261 | if err != nil { 262 | return -1, err 263 | } 264 | 265 | for i, m := range ms { 266 | if m.ID == mt.selectedMessageID { 267 | return i, nil 268 | } 269 | } 270 | 271 | return -1, nil 272 | } 273 | 274 | func (mt *messagesText) onInputCapture(event *tcell.EventKey) *tcell.EventKey { 275 | switch event.Name() { 276 | case mt.cfg.Keys.MessagesText.Cancel: 277 | mt.selectedMessageID = 0 278 | app.messageInput.replyMessageID = 0 279 | mt.Highlight() 280 | 281 | case mt.cfg.Keys.MessagesText.SelectPrevious, mt.cfg.Keys.MessagesText.SelectNext, mt.cfg.Keys.MessagesText.SelectFirst, mt.cfg.Keys.MessagesText.SelectLast, mt.cfg.Keys.MessagesText.SelectReply: 282 | mt._select(event.Name()) 283 | case mt.cfg.Keys.MessagesText.YankID: 284 | mt.yankID() 285 | case mt.cfg.Keys.MessagesText.YankContent: 286 | mt.yankContent() 287 | case mt.cfg.Keys.MessagesText.YankURL: 288 | mt.yankURL() 289 | case mt.cfg.Keys.MessagesText.Open: 290 | mt.open() 291 | case mt.cfg.Keys.MessagesText.Reply: 292 | mt.reply(false) 293 | case mt.cfg.Keys.MessagesText.ReplyMention: 294 | mt.reply(true) 295 | case mt.cfg.Keys.MessagesText.Delete: 296 | mt.delete() 297 | } 298 | 299 | return nil 300 | } 301 | 302 | func (mt *messagesText) _select(name string) { 303 | ms, err := discordState.Cabinet.Messages(app.guildsTree.selectedChannelID) 304 | if err != nil { 305 | slog.Error("failed to get messages", "err", err, "channel_id", app.guildsTree.selectedChannelID) 306 | return 307 | } 308 | 309 | msgIdx, err := mt.selectedMsgIndex() 310 | if err != nil { 311 | slog.Error("failed to get selected message", "err", err) 312 | return 313 | } 314 | 315 | switch name { 316 | case mt.cfg.Keys.MessagesText.SelectPrevious: 317 | // If no message is currently selected, select the latest message. 318 | if len(mt.GetHighlights()) == 0 { 319 | mt.selectedMessageID = ms[0].ID 320 | } else if msgIdx < len(ms)-1 { 321 | mt.selectedMessageID = ms[msgIdx+1].ID 322 | } else { 323 | return 324 | } 325 | case mt.cfg.Keys.MessagesText.SelectNext: 326 | // If no message is currently selected, select the latest message. 327 | if len(mt.GetHighlights()) == 0 { 328 | mt.selectedMessageID = ms[0].ID 329 | } else if msgIdx > 0 { 330 | mt.selectedMessageID = ms[msgIdx-1].ID 331 | } else { 332 | return 333 | } 334 | case mt.cfg.Keys.MessagesText.SelectFirst: 335 | mt.selectedMessageID = ms[len(ms)-1].ID 336 | case mt.cfg.Keys.MessagesText.SelectLast: 337 | mt.selectedMessageID = ms[0].ID 338 | case mt.cfg.Keys.MessagesText.SelectReply: 339 | if mt.selectedMessageID == 0 { 340 | return 341 | } 342 | 343 | if ref := ms[msgIdx].ReferencedMessage; ref != nil { 344 | for _, m := range ms { 345 | if ref.ID == m.ID { 346 | mt.selectedMessageID = m.ID 347 | } 348 | } 349 | } 350 | } 351 | 352 | mt.Highlight(mt.selectedMessageID.String()) 353 | mt.ScrollToHighlight() 354 | } 355 | 356 | func (mt *messagesText) onHighlighted(added, removed, remaining []string) { 357 | if len(added) > 0 { 358 | id, err := discord.ParseSnowflake(added[0]) 359 | if err != nil { 360 | slog.Error("Failed to parse region id as int to use as message id.", "err", err) 361 | return 362 | } 363 | 364 | mt.selectedMessageID = discord.MessageID(id) 365 | } 366 | } 367 | 368 | func (mt *messagesText) yankID() { 369 | msg, err := mt.selectedMsg() 370 | if err != nil { 371 | slog.Error("failed to get selected message", "err", err) 372 | return 373 | } 374 | 375 | if err := clipboard.WriteAll(msg.ID.String()); err != nil { 376 | slog.Error("failed to write to clipboard", "err", err) 377 | } 378 | } 379 | 380 | func (mt *messagesText) yankContent() { 381 | msg, err := mt.selectedMsg() 382 | if err != nil { 383 | slog.Error("failed to get selected message", "err", err) 384 | return 385 | } 386 | 387 | if err = clipboard.WriteAll(msg.Content); err != nil { 388 | slog.Error("failed to write to clipboard", "err", err) 389 | } 390 | } 391 | 392 | func (mt *messagesText) yankURL() { 393 | msg, err := mt.selectedMsg() 394 | if err != nil { 395 | slog.Error("failed to get selected message", "err", err) 396 | return 397 | } 398 | 399 | if err = clipboard.WriteAll(msg.URL()); err != nil { 400 | slog.Error("failed to write to clipboard", "err", err) 401 | } 402 | } 403 | 404 | func (mt *messagesText) open() { 405 | msg, err := mt.selectedMsg() 406 | if err != nil { 407 | slog.Error("failed to get selected message", "err", err) 408 | return 409 | } 410 | 411 | var urls []string 412 | if msg.Content != "" { 413 | urls = extractURLs(msg.Content) 414 | } 415 | 416 | if len(urls) == 0 && len(msg.Attachments) == 0 { 417 | return 418 | } 419 | 420 | if len(urls)+len(msg.Attachments) == 1 { 421 | if len(urls) == 1 { 422 | go openURL(urls[0]) 423 | } else { 424 | go openURL(msg.Attachments[0].URL) 425 | } 426 | } else { 427 | mt.showUrlSelector(urls, msg.Attachments) 428 | } 429 | } 430 | 431 | func extractURLs(content string) []string { 432 | src := []byte(content) 433 | node := parser.NewParser( 434 | parser.WithBlockParsers(discordmd.BlockParsers()...), 435 | parser.WithInlineParsers(discordmd.InlineParserWithLink()...), 436 | ).Parse(text.NewReader(src)) 437 | 438 | var urls []string 439 | ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 440 | if entering { 441 | switch n := n.(type) { 442 | case *ast.AutoLink: 443 | urls = append(urls, string(n.URL(src))) 444 | case *ast.Link: 445 | urls = append(urls, string(n.Destination)) 446 | } 447 | } 448 | 449 | return ast.WalkContinue, nil 450 | }) 451 | return urls 452 | } 453 | 454 | func (mt *messagesText) showUrlSelector(urls []string, attachments []discord.Attachment) { 455 | done := func() { 456 | app.pages.RemovePage("list").SwitchToPage("flex") 457 | app.SetFocus(app.messagesText) 458 | } 459 | 460 | list := tview.NewList(). 461 | SetWrapAround(true). 462 | SetHighlightFullLine(true). 463 | ShowSecondaryText(false). 464 | SetDoneFunc(done) 465 | 466 | b := mt.cfg.Theme.Border 467 | p := b.Padding 468 | list. 469 | SetBorder(b.Enabled). 470 | SetBorderColor(tcell.GetColor(b.Color)). 471 | SetBorderPadding(p[0], p[1], p[2], p[3]) 472 | 473 | list. 474 | SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 475 | switch event.Name() { 476 | case mt.cfg.Keys.MessagesText.SelectPrevious: 477 | return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) 478 | case mt.cfg.Keys.MessagesText.SelectNext: 479 | return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) 480 | case mt.cfg.Keys.MessagesText.SelectFirst: 481 | return tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone) 482 | case mt.cfg.Keys.MessagesText.SelectLast: 483 | return tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone) 484 | } 485 | 486 | return event 487 | }) 488 | 489 | for i, a := range attachments { 490 | attachment := a 491 | list.AddItem(a.Filename, "", rune('a'+i), func() { 492 | go openURL(attachment.URL) 493 | done() 494 | }) 495 | } 496 | 497 | for i, u := range urls { 498 | url := u 499 | list.AddItem(u, "", rune('1'+i), func() { 500 | go openURL(url) 501 | done() 502 | }) 503 | } 504 | 505 | app.pages. 506 | AddAndSwitchToPage("list", ui.Centered(list, 0, 0), true). 507 | ShowPage("flex") 508 | } 509 | 510 | func openURL(url string) { 511 | if err := open.Start(url); err != nil { 512 | slog.Error("failed to open URL", "err", err, "url", url) 513 | } 514 | } 515 | 516 | func (mt *messagesText) reply(mention bool) { 517 | var title string 518 | if mention { 519 | title += "[@] Replying to " 520 | } else { 521 | title += "Replying to " 522 | } 523 | 524 | msg, err := mt.selectedMsg() 525 | if err != nil { 526 | slog.Error("failed to get selected message", "err", err) 527 | return 528 | } 529 | 530 | title += mt.authorName(msg.Author, msg.GuildID) 531 | app.messageInput.SetTitle(title) 532 | app.messageInput.replyMessageID = mt.selectedMessageID 533 | mt.app.SetFocus(app.messageInput) 534 | } 535 | 536 | func (mt *messagesText) delete() { 537 | msg, err := mt.selectedMsg() 538 | if err != nil { 539 | slog.Error("failed to get selected message", "err", err) 540 | return 541 | } 542 | 543 | clientID := discordState.Ready().User.ID 544 | if msg.GuildID.IsValid() { 545 | ps, err := discordState.Permissions(app.guildsTree.selectedChannelID, discordState.Ready().User.ID) 546 | if err != nil { 547 | return 548 | } 549 | 550 | if msg.Author.ID != clientID && !ps.Has(discord.PermissionManageMessages) { 551 | return 552 | } 553 | } else { 554 | if msg.Author.ID != clientID { 555 | return 556 | } 557 | } 558 | 559 | if err := discordState.DeleteMessage(app.guildsTree.selectedChannelID, msg.ID, ""); err != nil { 560 | slog.Error("failed to delete message", "err", err, "channel_id", app.guildsTree.selectedChannelID, "message_id", msg.ID) 561 | return 562 | } 563 | 564 | if err := discordState.MessageRemove(app.guildsTree.selectedChannelID, msg.ID); err != nil { 565 | slog.Error("failed to delete message", "err", err, "channel_id", app.guildsTree.selectedChannelID, "message_id", msg.ID) 566 | return 567 | } 568 | 569 | ms, err := discordState.Cabinet.Messages(app.guildsTree.selectedChannelID) 570 | if err != nil { 571 | slog.Error("failed to delete message", "err", err, "channel_id", app.guildsTree.selectedChannelID) 572 | return 573 | } 574 | 575 | mt.Clear() 576 | 577 | for _, m := range slices.Backward(ms) { 578 | app.messagesText.createMsg(m) 579 | } 580 | } 581 | 582 | func (mt *messagesText) requestGuildMembers(gID discord.GuildID, ms []discord.Message) { 583 | var usersToFetch []discord.UserID 584 | for _, m := range ms { 585 | if member, _ := discordState.Cabinet.Member(gID, m.Author.ID); member == nil { 586 | usersToFetch = append(usersToFetch, m.Author.ID) 587 | } 588 | } 589 | 590 | if usersToFetch != nil { 591 | err := discordState.Gateway().Send(context.Background(), &gateway.RequestGuildMembersCommand{ 592 | GuildIDs: []discord.GuildID{gID}, 593 | UserIDs: slices.Compact(usersToFetch), 594 | }) 595 | if err != nil { 596 | slog.Error("failed to request guild members", "err", err) 597 | return 598 | } 599 | 600 | mt.setFetchingChunk(true) 601 | mt.waitForChunkEvent() 602 | } 603 | } 604 | 605 | func (mt *messagesText) setFetchingChunk(value bool) { 606 | mt.fetchingMembers.mu.Lock() 607 | defer mt.fetchingMembers.mu.Unlock() 608 | 609 | if mt.fetchingMembers.value == value { 610 | return 611 | } 612 | 613 | mt.fetchingMembers.value = value 614 | 615 | if value { 616 | mt.fetchingMembers.done = make(chan struct{}) 617 | } else { 618 | close(mt.fetchingMembers.done) 619 | } 620 | } 621 | 622 | func (mt *messagesText) waitForChunkEvent() { 623 | mt.fetchingMembers.mu.Lock() 624 | if !mt.fetchingMembers.value { 625 | mt.fetchingMembers.mu.Unlock() 626 | return 627 | } 628 | mt.fetchingMembers.mu.Unlock() 629 | 630 | select { 631 | case <-mt.fetchingMembers.done: 632 | default: 633 | <-mt.fetchingMembers.done 634 | } 635 | } 636 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | "log/slog" 6 | 7 | "github.com/ayn2op/discordo/internal/config" 8 | "github.com/ayn2op/discordo/internal/consts" 9 | "github.com/ayn2op/discordo/internal/logger" 10 | "github.com/diamondburned/arikawa/v3/utils/ws" 11 | "github.com/gdamore/tcell/v2" 12 | "github.com/rivo/tview" 13 | "github.com/zalando/go-keyring" 14 | ) 15 | 16 | var ( 17 | discordState *state 18 | app *application 19 | ) 20 | 21 | func Run() error { 22 | logLevel := flag.String("log-level", "info", "log level") 23 | logFormat := flag.String("log-format", "text", "log format") 24 | token := flag.String("token", "", "authentication token") 25 | configPath := flag.String("config", config.DefaultPath(), "path to the configuration file") 26 | flag.Parse() 27 | 28 | var level slog.Level 29 | switch *logLevel { 30 | case "debug": 31 | ws.EnableRawEvents = true 32 | level = slog.LevelDebug 33 | case "info": 34 | level = slog.LevelInfo 35 | case "warn": 36 | level = slog.LevelWarn 37 | case "error": 38 | level = slog.LevelError 39 | } 40 | 41 | var format logger.Format 42 | switch *logFormat { 43 | case "text": 44 | format = logger.FormatText 45 | case "json": 46 | format = logger.FormatJson 47 | } 48 | 49 | if err := logger.Load(format, level); err != nil { 50 | return err 51 | } 52 | 53 | tok := *token 54 | if tok == "" { 55 | var err error 56 | tok, err = keyring.Get(consts.Name, "token") 57 | if err != nil { 58 | slog.Info("failed to retrieve token from keyring", "err", err) 59 | } 60 | } 61 | 62 | cfg, err := config.Load(*configPath) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | tview.Styles.PrimitiveBackgroundColor = tcell.GetColor(cfg.Theme.BackgroundColor) 68 | 69 | tview.Borders.Horizontal = cfg.Theme.Border.Preset.Horizontal 70 | tview.Borders.Vertical = cfg.Theme.Border.Preset.Vertical 71 | tview.Borders.TopLeft = cfg.Theme.Border.Preset.TopLeft 72 | tview.Borders.TopRight = cfg.Theme.Border.Preset.TopRight 73 | tview.Borders.BottomLeft = cfg.Theme.Border.Preset.BottomLeft 74 | tview.Borders.BottomRight = cfg.Theme.Border.Preset.BottomRight 75 | 76 | tview.Borders.HorizontalFocus = tview.Borders.Horizontal 77 | tview.Borders.VerticalFocus = tview.Borders.Vertical 78 | tview.Borders.TopLeftFocus = tview.Borders.TopLeft 79 | tview.Borders.TopRightFocus = tview.Borders.TopRight 80 | tview.Borders.BottomLeftFocus = tview.Borders.BottomLeft 81 | tview.Borders.BottomRightFocus = tview.Borders.BottomRight 82 | 83 | app = newApp(cfg) 84 | return app.run(tok) 85 | } 86 | -------------------------------------------------------------------------------- /cmd/state.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "runtime" 7 | 8 | "github.com/ayn2op/discordo/internal/notifications" 9 | "github.com/diamondburned/arikawa/v3/api" 10 | "github.com/diamondburned/arikawa/v3/gateway" 11 | "github.com/diamondburned/arikawa/v3/utils/httputil/httpdriver" 12 | "github.com/diamondburned/arikawa/v3/utils/ws" 13 | "github.com/diamondburned/ningen/v3" 14 | "github.com/gdamore/tcell/v2" 15 | "github.com/rivo/tview" 16 | ) 17 | 18 | type state struct { 19 | *ningen.State 20 | } 21 | 22 | func openState(token string) error { 23 | api.UserAgent = app.cfg.Identify.UserAgent 24 | gateway.DefaultIdentity = gateway.IdentifyProperties{ 25 | OS: runtime.GOOS, 26 | Device: "", 27 | 28 | Browser: app.cfg.Identify.Browser, 29 | BrowserVersion: app.cfg.Identify.BrowserVersion, 30 | BrowserUserAgent: app.cfg.Identify.UserAgent, 31 | } 32 | 33 | gateway.DefaultPresence = &gateway.UpdatePresenceCommand{ 34 | Status: app.cfg.Identify.Status, 35 | } 36 | 37 | discordState = &state{ 38 | State: ningen.New(token), 39 | } 40 | 41 | // Handlers 42 | discordState.AddHandler(discordState.onReady) 43 | discordState.AddHandler(discordState.onMessageCreate) 44 | discordState.AddHandler(discordState.onMessageDelete) 45 | 46 | discordState.AddHandler(func(event *gateway.GuildMembersChunkEvent) { 47 | app.messagesText.setFetchingChunk(false) 48 | }) 49 | 50 | discordState.AddHandler(func(event *ws.RawEvent) { 51 | slog.Debug( 52 | "new raw event", 53 | "code", 54 | event.OriginalCode, 55 | "type", 56 | event.OriginalType, 57 | "data", 58 | event.Raw, 59 | ) 60 | }) 61 | 62 | discordState.StateLog = func(err error) { 63 | slog.Error("state log", "err", err) 64 | } 65 | 66 | discordState.OnRequest = append(discordState.OnRequest, discordState.onRequest) 67 | 68 | return discordState.Open(context.TODO()) 69 | } 70 | 71 | func (s *state) onRequest(r httpdriver.Request) error { 72 | req, ok := r.(*httpdriver.DefaultRequest) 73 | if ok { 74 | slog.Debug("new HTTP request", "method", req.Method, "url", req.URL) 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func (s *state) onReady(r *gateway.ReadyEvent) { 81 | root := app.guildsTree.GetRoot() 82 | root.ClearChildren() 83 | 84 | dmNode := tview.NewTreeNode("Direct Messages") 85 | dmNode.SetColor(tcell.GetColor(app.cfg.Theme.GuildsTree.PrivateChannelColor)) 86 | root.AddChild(dmNode) 87 | 88 | for _, folder := range r.UserSettings.GuildFolders { 89 | if folder.ID == 0 && len(folder.GuildIDs) == 1 { 90 | g, err := discordState.Cabinet.Guild(folder.GuildIDs[0]) 91 | if err != nil { 92 | slog.Error( 93 | "failed to get guild from state", 94 | "guild_id", 95 | folder.GuildIDs[0], 96 | "err", 97 | err, 98 | ) 99 | 100 | continue 101 | } 102 | 103 | app.guildsTree.createGuildNode(root, *g) 104 | } else { 105 | app.guildsTree.createFolderNode(folder) 106 | } 107 | } 108 | 109 | app.guildsTree.SetCurrentNode(root) 110 | app.SetFocus(app.guildsTree) 111 | } 112 | 113 | func (s *state) onMessageCreate(m *gateway.MessageCreateEvent) { 114 | if app.guildsTree.selectedChannelID.IsValid() && 115 | app.guildsTree.selectedChannelID == m.ChannelID { 116 | app.messagesText.createMsg(m.Message) 117 | } 118 | 119 | if err := notifications.HandleIncomingMessage(*s.State, m, app.cfg); err != nil { 120 | slog.Error("Notification failed", "err", err) 121 | } 122 | } 123 | 124 | func (s *state) onMessageDelete(m *gateway.MessageDeleteEvent) { 125 | if app.guildsTree.selectedChannelID == m.ChannelID { 126 | app.messagesText.selectedMessageID = 0 127 | app.messagesText.Highlight() 128 | app.messagesText.Clear() 129 | 130 | app.messagesText.drawMsgs(m.ChannelID) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1748026106, 6 | "narHash": "sha256-6m1Y3/4pVw1RWTsrkAK2VMYSzG4MMIj7sqUy7o8th1o=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "063f43f2dbdef86376cc29ad646c45c46e93234c", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A lightweight, secure, and feature-rich Discord terminal client."; 3 | 4 | inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 5 | 6 | outputs = { self, nixpkgs, ... }: 7 | let 8 | systems = [ 9 | "x86_64-linux" 10 | "aarch64-linux" 11 | "x86_64-darwin" 12 | "aarch64-darwin" 13 | ]; 14 | forAllSystems = f: 15 | nixpkgs.lib.genAttrs systems 16 | (system: f { 17 | inherit system; 18 | pkgs = nixpkgs.legacyPackages.${system}; 19 | packages' = self.packages.${system}; 20 | }); 21 | in 22 | { 23 | packages = forAllSystems ({ pkgs, packages', ... }: { 24 | default = packages'.discordo; 25 | discordo = pkgs.callPackage ./nix/package.nix { }; 26 | }); 27 | homeModules = { 28 | default = self.homeModules.discordo; 29 | discordo = import ./nix/module-hm.nix self; 30 | }; 31 | devShells.default = forAllSystems ({ pkgs, packages', ... }: pkgs.mkShell { 32 | inputsFrom = [ packages'.discordo ]; 33 | }); 34 | }; 35 | } 36 | 37 | 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ayn2op/discordo 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.5.0 7 | github.com/atotto/clipboard v0.1.4 8 | github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb 9 | github.com/diamondburned/arikawa/v3 v3.5.0 10 | github.com/diamondburned/ningen/v3 v3.0.1-0.20240808103805-f1a24c0da3d8 11 | github.com/gdamore/tcell/v2 v2.8.1 12 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 13 | github.com/rivo/tview v0.0.0-20250501113434-0c592cd31026 14 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 15 | github.com/yuin/goldmark v1.7.11 16 | github.com/zalando/go-keyring v0.2.6 17 | ) 18 | 19 | require ( 20 | al.essio.dev/pkg/shellescape v1.6.0 // indirect 21 | github.com/danieljoos/wincred v1.2.2 // indirect 22 | github.com/gdamore/encoding v1.0.1 // indirect 23 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect 24 | github.com/godbus/dbus/v5 v5.1.0 // indirect 25 | github.com/gorilla/schema v1.4.1 // indirect 26 | github.com/gorilla/websocket v1.5.3 // indirect 27 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 28 | github.com/mattn/go-runewidth v0.0.16 // indirect 29 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect 30 | github.com/pkg/errors v0.9.1 // indirect 31 | github.com/rivo/uniseg v0.4.7 // indirect 32 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 33 | github.com/twmb/murmur3 v1.1.8 // indirect 34 | go4.org v0.0.0-20230225012048-214862532bf5 // indirect 35 | golang.org/x/sys v0.33.0 // indirect 36 | golang.org/x/term v0.32.0 // indirect 37 | golang.org/x/text v0.25.0 // indirect 38 | golang.org/x/time v0.11.0 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= 2 | al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= 3 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 4 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 5 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 6 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 7 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 8 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 9 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 10 | cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= 11 | cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= 12 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 13 | cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= 14 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 15 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 16 | cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= 17 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 18 | cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= 19 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 20 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 21 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 22 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 23 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 24 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 25 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 26 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 27 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 28 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 29 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 30 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 31 | github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= 32 | github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= 33 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 35 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 36 | github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb h1:6S+TKObz6+Io2c8IOkcbK4Sz7nj6RpEVU7TkvmsZZcw= 37 | github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb/go.mod h1:wf3nKtOnQqCp7kp9xB7hHnNlZ6m3NoiOxjrB9hFRq4Y= 38 | github.com/diamondburned/arikawa/v3 v3.5.0 h1:Ct93e1kZBLqlwTB88oOSZ/B0zzqrn5MpRwfGYiH81d8= 39 | github.com/diamondburned/arikawa/v3 v3.5.0/go.mod h1:thocAM2X8lRDHuEZR5vWYaT4w+tb/vOKa1qm+r0gs5A= 40 | github.com/diamondburned/ningen/v3 v3.0.1-0.20240808103805-f1a24c0da3d8 h1:wgvgSzI4N+BHhCWhGhHKfW4gm0UtBVptiDaBGPdHmcs= 41 | github.com/diamondburned/ningen/v3 v3.0.1-0.20240808103805-f1a24c0da3d8/go.mod h1:UU1lud9g/GBl2+CZ8nPCe3Qk1U6fABEP1fk1sUzo7w0= 42 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 43 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 44 | github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= 45 | github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= 46 | github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= 47 | github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= 48 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI= 49 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4= 50 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 51 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 52 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= 53 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= 54 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 55 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 56 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 57 | github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 58 | github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 59 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 60 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 61 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 62 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 63 | github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 64 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 65 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 66 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 67 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 68 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 69 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 70 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 71 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 72 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 73 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 74 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 75 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 76 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 77 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 78 | github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 79 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 80 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 81 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 82 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 83 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 84 | github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= 85 | github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= 86 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 87 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 88 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 89 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 90 | github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 91 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 92 | github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= 93 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 94 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 95 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 96 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 97 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 98 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 99 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 100 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 101 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 102 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 103 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 104 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 105 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 106 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 107 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 108 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 109 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 110 | github.com/rivo/tview v0.0.0-20250501113434-0c592cd31026 h1:ij8h8B3psk3LdMlqkfPTKIzeGzTaZLOiyplILMlxPAM= 111 | github.com/rivo/tview v0.0.0-20250501113434-0c592cd31026/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= 112 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 113 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 114 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 115 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 116 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 117 | github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= 118 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= 119 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= 120 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 121 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 122 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 123 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 124 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 125 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 126 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= 127 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= 128 | github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= 129 | github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= 130 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 131 | github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo= 132 | github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 133 | github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= 134 | github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= 135 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 136 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 137 | go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 138 | go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 139 | go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= 140 | go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= 141 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 142 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 143 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 144 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 145 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 146 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 147 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 148 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 149 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 150 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 151 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 152 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 153 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 154 | golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 155 | golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 156 | golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= 157 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 158 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 159 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 160 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 161 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 162 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 163 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 164 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 165 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 166 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= 167 | golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 168 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 169 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 170 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 171 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 172 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 173 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 174 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 175 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 176 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 177 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 178 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 179 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 180 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 181 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 182 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 183 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 184 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 185 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 186 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 187 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 188 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 189 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 190 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 191 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 192 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 193 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 194 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 195 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 196 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 197 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 198 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 199 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 200 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 201 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 202 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 203 | golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 204 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 205 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 206 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 207 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 208 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 209 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 210 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 211 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 212 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 213 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 214 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 215 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 216 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 217 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 218 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 219 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 220 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 221 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 222 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 223 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 224 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 225 | golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 226 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 227 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 228 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 229 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 230 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 231 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 232 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 233 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 234 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 235 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 236 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 237 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 238 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 239 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 240 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 241 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 242 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 243 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 244 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 245 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 246 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 247 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 248 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 249 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 250 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 251 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 252 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 253 | golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 254 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 255 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 256 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 257 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 258 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 259 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 260 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 261 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 262 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 263 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 264 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 265 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 266 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 267 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 268 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 269 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 270 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 271 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 272 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 273 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 274 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 275 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 276 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 277 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 278 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 279 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 280 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 281 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 282 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 283 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 284 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 285 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 286 | golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 287 | golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 288 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 289 | golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 290 | golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 291 | golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 292 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 293 | golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 294 | golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 295 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 296 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 297 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 298 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 299 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 300 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 301 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 302 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 303 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 304 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 305 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 306 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 307 | google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 308 | google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 309 | google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 310 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 311 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 312 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 313 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 314 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 315 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 316 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 317 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 318 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 319 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 320 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 321 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 322 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 323 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 324 | google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 325 | google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 326 | google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 327 | google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 328 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 329 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 330 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 331 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 332 | google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 333 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 334 | google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 335 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 336 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 337 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 338 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 339 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 340 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 341 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 342 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 343 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 344 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 345 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 346 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 347 | rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= 348 | rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= 349 | -------------------------------------------------------------------------------- /internal/config/border.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/rivo/tview" 4 | 5 | type BorderPreset struct { 6 | Horizontal rune 7 | Vertical rune 8 | TopLeft rune 9 | TopRight rune 10 | BottomLeft rune 11 | BottomRight rune 12 | } 13 | 14 | func (p *BorderPreset) UnmarshalTOML(v any) error { 15 | switch v.(string) { 16 | case "double": 17 | *p = borderPresetDouble() 18 | case "thick": 19 | *p = borderPresetThick() 20 | case "round": 21 | *p = borderPresetRound() 22 | case "light": 23 | *p = borderPresetLight() 24 | case "hidden": 25 | *p = BorderPreset{ 26 | Horizontal: ' ', 27 | Vertical: ' ', 28 | TopLeft: ' ', 29 | TopRight: ' ', 30 | BottomLeft: ' ', 31 | BottomRight: ' ', 32 | } 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func borderPresetDouble() BorderPreset { 39 | return BorderPreset{ 40 | Horizontal: tview.BoxDrawingsDoubleHorizontal, 41 | Vertical: tview.BoxDrawingsDoubleVertical, 42 | TopLeft: tview.BoxDrawingsDoubleDownAndRight, 43 | TopRight: tview.BoxDrawingsDoubleDownAndLeft, 44 | BottomLeft: tview.BoxDrawingsDoubleUpAndRight, 45 | BottomRight: tview.BoxDrawingsDoubleUpAndLeft, 46 | } 47 | } 48 | 49 | func borderPresetThick() BorderPreset { 50 | return BorderPreset{ 51 | Horizontal: tview.BoxDrawingsHeavyHorizontal, 52 | Vertical: tview.BoxDrawingsHeavyVertical, 53 | TopLeft: tview.BoxDrawingsHeavyDownAndRight, 54 | TopRight: tview.BoxDrawingsHeavyDownAndLeft, 55 | BottomLeft: tview.BoxDrawingsHeavyUpAndRight, 56 | BottomRight: tview.BoxDrawingsHeavyUpAndLeft, 57 | } 58 | } 59 | 60 | func borderPresetRound() BorderPreset { 61 | return BorderPreset{ 62 | Horizontal: tview.BoxDrawingsLightHorizontal, 63 | Vertical: tview.BoxDrawingsLightVertical, 64 | TopLeft: tview.BoxDrawingsLightArcDownAndRight, 65 | TopRight: tview.BoxDrawingsLightArcDownAndLeft, 66 | BottomLeft: tview.BoxDrawingsLightArcUpAndRight, 67 | BottomRight: tview.BoxDrawingsLightArcUpAndLeft, 68 | } 69 | } 70 | 71 | func borderPresetLight() BorderPreset { 72 | return BorderPreset{ 73 | Horizontal: tview.BoxDrawingsLightHorizontal, 74 | Vertical: tview.BoxDrawingsLightVertical, 75 | TopLeft: tview.BoxDrawingsLightDownAndRight, 76 | TopRight: tview.BoxDrawingsLightDownAndLeft, 77 | BottomLeft: tview.BoxDrawingsLightUpAndRight, 78 | BottomRight: tview.BoxDrawingsLightUpAndLeft, 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/BurntSushi/toml" 11 | "github.com/ayn2op/discordo/internal/consts" 12 | "github.com/diamondburned/arikawa/v3/discord" 13 | ) 14 | 15 | const fileName = "config.toml" 16 | 17 | type ( 18 | Timestamps struct { 19 | Enabled bool `toml:"enabled"` 20 | Format string `toml:"format"` 21 | } 22 | 23 | Identify struct { 24 | Status discord.Status `toml:"status"` 25 | Browser string `toml:"browser"` 26 | BrowserVersion string `toml:"browser_version"` 27 | UserAgent string `toml:"user_agent"` 28 | } 29 | 30 | Notifications struct { 31 | Enabled bool `toml:"enabled"` 32 | Duration int `toml:"duration"` 33 | Sound Sound `toml:"sound"` 34 | } 35 | 36 | Sound struct { 37 | Enabled bool `toml:"enabled"` 38 | OnlyOnPing bool `toml:"only_on_ping"` 39 | } 40 | 41 | Config struct { 42 | Mouse bool `toml:"mouse"` 43 | Editor string `toml:"editor"` 44 | 45 | Markdown bool `toml:"markdown"` 46 | HideBlockedUsers bool `toml:"hide_blocked_users"` 47 | ShowAttachmentLinks bool `toml:"show_attachment_links"` 48 | MessagesLimit uint8 `toml:"messages_limit"` 49 | 50 | Timestamps Timestamps `toml:"timestamps"` 51 | Identify Identify `toml:"identify"` 52 | Notifications Notifications `toml:"notifications"` 53 | 54 | Keys Keys `toml:"keys"` 55 | Theme Theme `toml:"theme"` 56 | } 57 | ) 58 | 59 | //go:embed config.toml 60 | var defaultCfg []byte 61 | 62 | func DefaultPath() string { 63 | path, err := os.UserConfigDir() 64 | if err != nil { 65 | slog.Info( 66 | "user configuration directory path cannot be determined; falling back to the current directory path", 67 | ) 68 | path = "." 69 | } 70 | 71 | return filepath.Join(path, consts.Name, fileName) 72 | } 73 | 74 | // Load reads the configuration file and parses it. 75 | func Load(path string) (*Config, error) { 76 | file, err := os.Open(path) 77 | 78 | var cfg *Config 79 | if err := toml.Unmarshal(defaultCfg, &cfg); err != nil { 80 | return nil, fmt.Errorf("failed to unmarshal default config: %w", err) 81 | } 82 | 83 | if os.IsNotExist(err) { 84 | slog.Info( 85 | "the configuration file does not exist, falling back to the default configuration", 86 | "path", 87 | path, 88 | "err", 89 | err, 90 | ) 91 | handleDefaults(cfg) 92 | return cfg, nil 93 | } 94 | 95 | if err != nil { 96 | return nil, fmt.Errorf("failed to open config file: %w", err) 97 | } 98 | defer file.Close() 99 | 100 | if _, err := toml.NewDecoder(file).Decode(&cfg); err != nil { 101 | return nil, fmt.Errorf("failed to decode config: %w", err) 102 | } 103 | 104 | handleDefaults(cfg) 105 | return cfg, nil 106 | } 107 | 108 | func handleDefaults(cfg *Config) { 109 | if cfg.Editor == "default" { 110 | cfg.Editor = os.Getenv("EDITOR") 111 | } 112 | 113 | if cfg.Identify.Browser == "default" { 114 | cfg.Identify.Browser = consts.Browser 115 | } 116 | 117 | if cfg.Identify.BrowserVersion == "default" { 118 | cfg.Identify.BrowserVersion = consts.BrowserVersion 119 | } 120 | 121 | if cfg.Identify.UserAgent == "default" { 122 | cfg.Identify.UserAgent = consts.UserAgent 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /internal/config/config.toml: -------------------------------------------------------------------------------- 1 | # Enable mouse controls 2 | mouse = true 3 | 4 | # "default" means use $EDITOR. 5 | editor = "default" 6 | 7 | hide_blocked_users = true 8 | show_attachment_links = true 9 | messages_limit = 50 10 | # Whether to parse and render markdown in messages or not. 11 | markdown = true 12 | 13 | # Timestamps uses Go timestamp format 14 | # See: https://gosamples.dev/date-time-format-cheatsheet 15 | [timestamps] 16 | enabled = true 17 | format = "3:04PM" 18 | 19 | [notifications] 20 | enabled = true 21 | duration = 500 22 | [notifications.sound] 23 | enabled = true 24 | only_on_ping = true 25 | 26 | # How Discord sees us. 27 | # status: "online", "idle", "dnd" (Do Not Disturb), "" (invisible), 28 | # note: does not seem to work 29 | [identify] 30 | status = "online" 31 | browser = "default" 32 | browser_version = "default" 33 | user_agent = "default" 34 | 35 | # Global shortcuts 36 | # Esc: Reset message selection or close the channel selection popup. 37 | [keys] 38 | focus_guilds_tree = "Ctrl+G" 39 | focus_messages_text = "Ctrl+T" 40 | focus_message_input = "Ctrl+P" 41 | # Hide/show the guilds tree 42 | toggle_guilds_tree = "Ctrl+B" 43 | quit = "Ctrl+C" 44 | # Log out and remove the authentication token from keyring. 45 | # Requires re-login upon restart. 46 | logout = "Ctrl+D" 47 | 48 | # Only while focusing on the guilds tree 49 | [keys.guilds_tree] 50 | select_previous = "Rune[k]" 51 | select_next = "Rune[j]" 52 | select_first = "Rune[g]" 53 | select_last = "Rune[G]" 54 | # Select the currently highlighted text-based channel or expand a guild or channel. 55 | select_current = "Enter" 56 | yank_id = "Rune[i]" 57 | collapse_parent_node = "Rune[-]" 58 | move_to_parent_node = "Rune[p]" 59 | 60 | # Only while focusing on sent messages 61 | [keys.messages_text] 62 | select_previous = "Rune[k]" 63 | select_next = "Rune[j]" 64 | select_first = "Rune[g]" 65 | select_last = "Rune[G]" 66 | # Select the message reference (reply) of the selected channel. 67 | select_reply = "Rune[s]" 68 | # Reply to the selected message. 69 | reply = "Rune[r]" 70 | # Reply (with mention) to the selected message. 71 | reply_mention = "Rune[R]" 72 | cancel = "Esc" 73 | delete = "Rune[d]" 74 | # Open the selected message's attachments or hyperlinks in the message 75 | # using the default browser application. 76 | open = "Rune[o]" 77 | # Yank (copy) the selected message's content/url/id. 78 | yank_content = "Rune[y]" 79 | yank_url = "Rune[u]" 80 | yank_id = "Rune[i]" 81 | 82 | # Only while typing a message 83 | # Alt+Enter: Insert a new line to the current text. 84 | [keys.message_input] 85 | # Send the message. 86 | send = "Enter" 87 | # Open message input in your editor. 88 | editor = "Ctrl+E" 89 | # Remove existing text or cancel reply. 90 | cancel = "Esc" 91 | 92 | # Applies to all 93 | [theme] 94 | background_color = "default" 95 | 96 | [theme.title] 97 | # Title color of non-focused widgets 98 | color = "default" 99 | # Title color of the focused widget 100 | active_color = "green" 101 | align = "left" 102 | 103 | [theme.border] 104 | enabled = true 105 | # [top, bottom, left, right] 106 | padding = [0, 0, 1, 1] 107 | color = "default" 108 | active_color = "green" 109 | preset = "round" 110 | 111 | [theme.guilds_tree] 112 | auto_expand_folders = true 113 | # Give tree-like shape 114 | graphics = true 115 | private_channel_color = "white" 116 | guild_color = "white" 117 | channel_color = "white" 118 | 119 | [theme.messages_text] 120 | # Set to false to show messages with usernames instead of nicknames 121 | show_user_nicks = true 122 | show_user_colors = true 123 | reply_indicator = ">" 124 | forwarded_indicator = "<" 125 | author_color = "aqua" 126 | content_color = "white" 127 | emoji_color = "green" 128 | link_color = "blue" 129 | attachment_color = "yellow" 130 | -------------------------------------------------------------------------------- /internal/config/keys.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type ( 4 | NavigationKeys struct { 5 | SelectPrevious string `toml:"select_previous"` 6 | SelectNext string `toml:"select_next"` 7 | SelectFirst string `toml:"select_first"` 8 | SelectLast string `toml:"select_last"` 9 | } 10 | 11 | Keys struct { 12 | FocusGuildsTree string `toml:"focus_guilds_tree"` 13 | FocusMessagesText string `toml:"focus_messages_text"` 14 | FocusMessageInput string `toml:"focus_message_input"` 15 | ToggleGuildsTree string `toml:"toggle_guilds_tree"` 16 | 17 | GuildsTree GuildsTreeKeys `toml:"guilds_tree"` 18 | MessagesText MessagesTextKeys `toml:"messages_text"` 19 | MessageInput MessageInputKeys `toml:"message_input"` 20 | 21 | Logout string `toml:"logout"` 22 | Quit string `toml:"quit"` 23 | } 24 | 25 | GuildsTreeKeys struct { 26 | NavigationKeys 27 | SelectCurrent string `toml:"select_current"` 28 | YankID string `toml:"yank_id"` 29 | 30 | CollapseParentNode string `toml:"collapse_parent_node"` 31 | MoveToParentNode string `toml:"move_to_parent_node"` 32 | } 33 | 34 | MessagesTextKeys struct { 35 | NavigationKeys 36 | SelectReply string `toml:"select_reply"` 37 | Reply string `toml:"reply"` 38 | ReplyMention string `toml:"reply_mention"` 39 | 40 | Cancel string `toml:"cancel"` 41 | Delete string `toml:"delete"` 42 | Open string `toml:"open"` 43 | 44 | YankContent string `toml:"yank_content"` 45 | YankURL string `toml:"yank_url"` 46 | YankID string `toml:"yank_id"` 47 | } 48 | 49 | MessageInputKeys struct { 50 | Send string `toml:"send"` 51 | Editor string `toml:"editor"` 52 | Cancel string `toml:"cancel"` 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /internal/config/theme.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/rivo/tview" 5 | ) 6 | 7 | type TitleAlign int 8 | 9 | func (ta *TitleAlign) UnmarshalTOML(v any) error { 10 | switch v.(string) { 11 | case "left": 12 | *ta = tview.AlignLeft 13 | case "center": 14 | *ta = tview.AlignCenter 15 | case "right": 16 | *ta = tview.AlignRight 17 | } 18 | 19 | return nil 20 | } 21 | 22 | type ( 23 | BorderTheme struct { 24 | Enabled bool `toml:"enabled"` 25 | Padding [4]int `toml:"padding"` 26 | 27 | Color string `toml:"color"` 28 | ActiveColor string `toml:"active_color"` 29 | 30 | Preset BorderPreset `toml:"preset"` 31 | } 32 | 33 | TitleTheme struct { 34 | Color string `toml:"color"` 35 | ActiveColor string `toml:"active_color"` 36 | Align TitleAlign `toml:"align"` 37 | } 38 | 39 | Theme struct { 40 | BackgroundColor string `toml:"background_color"` 41 | 42 | Title TitleTheme `toml:"title"` 43 | Border BorderTheme `toml:"border"` 44 | GuildsTree GuildsTreeTheme `toml:"guilds_tree"` 45 | MessagesText MessagesTextTheme `toml:"messages_text"` 46 | } 47 | 48 | GuildsTreeTheme struct { 49 | AutoExpandFolders bool `toml:"auto_expand_folders"` 50 | Graphics bool `toml:"graphics"` 51 | 52 | PrivateChannelColor string `toml:"private_channel_color"` 53 | GuildColor string `toml:"guild_color"` 54 | ChannelColor string `toml:"channel_color"` 55 | } 56 | 57 | MessagesTextTheme struct { 58 | ShowNicknames bool `toml:"show_user_nicks"` 59 | ShowUsernameColors bool `toml:"show_user_colors"` 60 | 61 | ReplyIndicator string `toml:"reply_indicator"` 62 | ForwardedIndicator string `toml:"forwarded_indicator"` 63 | 64 | AuthorColor string `toml:"author_color"` 65 | ContentColor string `toml:"content_color"` 66 | EmojiColor string `toml:"emoji_color"` 67 | LinkColor string `toml:"link_color"` 68 | AttachmentColor string `toml:"attachment_color"` 69 | } 70 | ) 71 | -------------------------------------------------------------------------------- /internal/consts/consts.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const ( 4 | Name = "discordo" 5 | ) 6 | -------------------------------------------------------------------------------- /internal/consts/consts_darwin.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const ( 4 | Browser = "Chrome" 5 | BrowserVersion = "132.0.0.0" 6 | UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) " + Browser + "/" + BrowserVersion + " Safari/537.36" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/consts/consts_linux.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const ( 4 | Browser = "Chrome" 5 | BrowserVersion = "133.0.0.0" 6 | UserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " + Browser + "/" + BrowserVersion + " Safari/537.36" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/consts/consts_windows.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const ( 4 | Browser = "Chrome" 5 | BrowserVersion = "133.0.0.0" 6 | UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + Browser + "/" + BrowserVersion + " Safari/537.36" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/ayn2op/discordo/internal/consts" 9 | ) 10 | 11 | type Format int 12 | 13 | const ( 14 | FormatText Format = iota 15 | FormatJson 16 | ) 17 | 18 | // Opens the log file and configures default logger. 19 | func Load(format Format, level slog.Level) error { 20 | path, err := os.UserCacheDir() 21 | if err != nil { 22 | return err 23 | } 24 | 25 | path = filepath.Join(path, consts.Name) 26 | if err := os.MkdirAll(path, os.ModePerm); err != nil { 27 | return err 28 | } 29 | 30 | opts := &slog.HandlerOptions{AddSource: true, Level: level} 31 | 32 | var h slog.Handler 33 | switch format { 34 | case FormatText: 35 | path := filepath.Join(path, "logs.txt") 36 | file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, os.ModePerm) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | h = slog.NewTextHandler(file, opts) 42 | case FormatJson: 43 | path := filepath.Join(path, "logs.jsonl") 44 | file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, os.ModePerm) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | h = slog.NewJSONHandler(file, opts) 50 | } 51 | 52 | slog.SetDefault(slog.New(h)) 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /internal/login/form.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | 7 | "github.com/ayn2op/discordo/internal/config" 8 | "github.com/ayn2op/discordo/internal/consts" 9 | "github.com/ayn2op/discordo/internal/ui" 10 | "github.com/diamondburned/arikawa/v3/api" 11 | "github.com/rivo/tview" 12 | "github.com/zalando/go-keyring" 13 | ) 14 | 15 | type DoneFn = func(token string) 16 | 17 | type Form struct { 18 | *tview.Pages 19 | cfg *config.Config 20 | app *tview.Application 21 | form *tview.Form 22 | done DoneFn 23 | } 24 | 25 | func NewForm(cfg *config.Config, app *tview.Application, done DoneFn) *Form { 26 | f := &Form{ 27 | Pages: tview.NewPages(), 28 | cfg: cfg, 29 | app: app, 30 | form: tview.NewForm(), 31 | done: done, 32 | } 33 | 34 | f.form. 35 | AddInputField("Email", "", 0, nil, nil). 36 | AddPasswordField("Password", "", 0, 0, nil). 37 | AddPasswordField("Code (optional)", "", 0, 0, nil). 38 | AddButton("Login", f.login) 39 | f.AddAndSwitchToPage("form", f.form, true) 40 | return f 41 | } 42 | 43 | func (f *Form) login() { 44 | email := f.form.GetFormItem(0).(*tview.InputField).GetText() 45 | password := f.form.GetFormItem(1).(*tview.InputField).GetText() 46 | if email == "" || password == "" { 47 | return 48 | } 49 | 50 | // Create an API client without an authentication token. 51 | client := api.NewClient("") 52 | // Spoof the user agent of a web browser. 53 | client.UserAgent = f.cfg.Identify.UserAgent 54 | 55 | resp, err := client.Login(email, password) 56 | if err != nil { 57 | f.onError(err) 58 | return 59 | } 60 | 61 | if resp.Token == "" && resp.MFA { 62 | code := f.form.GetFormItem(2).(*tview.InputField).GetText() 63 | if code == "" { 64 | f.onError(errors.New("code required")) 65 | return 66 | } 67 | 68 | // Attempt to login using the code. 69 | resp, err = client.TOTP(code, resp.Ticket) 70 | if err != nil { 71 | f.onError(err) 72 | return 73 | } 74 | } 75 | 76 | if resp.Token == "" { 77 | f.onError(errors.New("missing token")) 78 | return 79 | } 80 | 81 | go keyring.Set(consts.Name, "token", resp.Token) 82 | 83 | if f.done != nil { 84 | f.done(resp.Token) 85 | } 86 | } 87 | 88 | func (f *Form) onError(err error) { 89 | slog.Error("failed to login", "err", err) 90 | 91 | modal := tview.NewModal(). 92 | SetText(err.Error()). 93 | AddButtons([]string{"Close"}). 94 | SetDoneFunc(func(_ int, _ string) { 95 | f.RemovePage("modal").SwitchToPage("form") 96 | }) 97 | f. 98 | AddAndSwitchToPage("modal", ui.Centered(modal, 0, 0), true). 99 | ShowPage("form") 100 | } 101 | -------------------------------------------------------------------------------- /internal/markdown/renderer.go: -------------------------------------------------------------------------------- 1 | package markdown 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/diamondburned/ningen/v3/discordmd" 8 | "github.com/yuin/goldmark/ast" 9 | gmr "github.com/yuin/goldmark/renderer" 10 | ) 11 | 12 | var DefaultRenderer = newRenderer() 13 | 14 | type renderer struct { 15 | config *gmr.Config 16 | } 17 | 18 | func newRenderer() *renderer { 19 | config := gmr.NewConfig() 20 | return &renderer{config} 21 | } 22 | 23 | // AddOptions implements renderer.Renderer. 24 | func (r *renderer) AddOptions(opts ...gmr.Option) { 25 | for _, opt := range opts { 26 | opt.SetConfig(r.config) 27 | } 28 | } 29 | 30 | func (r *renderer) Render(w io.Writer, source []byte, n ast.Node) error { 31 | return ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 32 | switch n := n.(type) { 33 | case *ast.Document: 34 | // noop 35 | case *ast.Heading: 36 | r.renderHeading(w) 37 | case *ast.Text: 38 | r.renderText(w, n, entering, source) 39 | case *ast.FencedCodeBlock: 40 | r.renderFencedCodeBlock(w, n, entering, source) 41 | case *ast.AutoLink: 42 | r.renderAutoLink(w, n, entering, source) 43 | case *ast.Link: 44 | r.renderLink(w, n, entering) 45 | 46 | case *discordmd.Inline: 47 | r.renderInline(w, n, entering) 48 | case *discordmd.Mention: 49 | r.renderMention(w, n, entering) 50 | case *discordmd.Emoji: 51 | r.renderEmoji(w, n, entering) 52 | } 53 | 54 | return ast.WalkContinue, nil 55 | }) 56 | } 57 | 58 | func (r *renderer) renderHeading(w io.Writer) { 59 | io.WriteString(w, "\n") 60 | } 61 | 62 | func (r *renderer) renderFencedCodeBlock(w io.Writer, n *ast.FencedCodeBlock, entering bool, source []byte) { 63 | io.WriteString(w, "\n") 64 | 65 | if entering { 66 | // body 67 | lines := n.Lines() 68 | for i := range lines.Len() { 69 | line := lines.At(i) 70 | io.WriteString(w, "| ") 71 | w.Write(line.Value(source)) 72 | } 73 | } 74 | } 75 | 76 | func (r *renderer) renderAutoLink(w io.Writer, n *ast.AutoLink, entering bool, source []byte) { 77 | if entering { 78 | linkColor := r.config.Options["linkColor"].(string) 79 | io.WriteString(w, "["+linkColor+"]") 80 | w.Write(n.URL(source)) 81 | } else { 82 | io.WriteString(w, "[-::]") 83 | } 84 | } 85 | 86 | func (r *renderer) renderLink(w io.Writer, n *ast.Link, entering bool) { 87 | if entering { 88 | linkColor := r.config.Options["linkColor"].(string) 89 | io.WriteString(w, fmt.Sprintf("[%s:::%s]", linkColor, n.Destination)) 90 | } else { 91 | io.WriteString(w, "[-:::-]") 92 | } 93 | } 94 | 95 | func (r *renderer) renderText(w io.Writer, n *ast.Text, entering bool, source []byte) { 96 | if entering { 97 | w.Write(n.Segment.Value(source)) 98 | switch { 99 | case n.HardLineBreak(): 100 | io.WriteString(w, "\n\n") 101 | case n.SoftLineBreak(): 102 | io.WriteString(w, "\n") 103 | } 104 | } 105 | } 106 | 107 | func (r *renderer) renderInline(w io.Writer, n *discordmd.Inline, entering bool) { 108 | if entering { 109 | switch n.Attr { 110 | case discordmd.AttrBold: 111 | io.WriteString(w, "[::b]") 112 | case discordmd.AttrItalics: 113 | io.WriteString(w, "[::i]") 114 | case discordmd.AttrUnderline: 115 | io.WriteString(w, "[::u]") 116 | case discordmd.AttrStrikethrough: 117 | io.WriteString(w, "[::s]") 118 | case discordmd.AttrMonospace: 119 | io.WriteString(w, "[::r]") 120 | } 121 | } else { 122 | switch n.Attr { 123 | case discordmd.AttrBold: 124 | io.WriteString(w, "[::B]") 125 | case discordmd.AttrItalics: 126 | io.WriteString(w, "[::I]") 127 | case discordmd.AttrUnderline: 128 | io.WriteString(w, "[::U]") 129 | case discordmd.AttrStrikethrough: 130 | io.WriteString(w, "[::S]") 131 | case discordmd.AttrMonospace: 132 | io.WriteString(w, "[::R]") 133 | } 134 | } 135 | } 136 | 137 | func (r *renderer) renderMention(w io.Writer, n *discordmd.Mention, entering bool) { 138 | if entering { 139 | io.WriteString(w, "[::b]") 140 | 141 | switch { 142 | case n.Channel != nil: 143 | io.WriteString(w, "#"+n.Channel.Name) 144 | case n.GuildUser != nil: 145 | username := n.GuildUser.DisplayOrUsername() 146 | if r.config.Options["showNicknames"].(bool) && n.GuildUser.Member != nil && n.GuildUser.Member.Nick != "" { 147 | username = n.GuildUser.Member.Nick 148 | } 149 | io.WriteString(w, "@"+username) 150 | case n.GuildRole != nil: 151 | io.WriteString(w, "@"+n.GuildRole.Name) 152 | } 153 | } else { 154 | io.WriteString(w, "[::B]") 155 | } 156 | } 157 | 158 | func (r *renderer) renderEmoji(w io.Writer, n *discordmd.Emoji, entering bool) { 159 | if entering { 160 | emojiColor := r.config.Options["emojiColor"].(string) 161 | io.WriteString(w, "["+emojiColor+"]") 162 | io.WriteString(w, ":"+n.Name+":") 163 | } else { 164 | io.WriteString(w, "[-]") 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /internal/notifications/desktop_toast.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin 2 | 3 | package notifications 4 | 5 | import "github.com/gen2brain/beeep" 6 | 7 | func sendDesktopNotification(title string, body string, image string, playSound bool, duration int) error { 8 | beeep.DefaultDuration = duration 9 | 10 | if err := beeep.Notify(title, body, image); err != nil { 11 | return err 12 | } 13 | 14 | if playSound { 15 | return beeep.Beep(beeep.DefaultFreq, beeep.DefaultDuration) 16 | } 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /internal/notifications/desktop_toast_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package notifications 4 | 5 | import ( 6 | gosxnotifier "github.com/deckarep/gosx-notifier" 7 | ) 8 | 9 | func sendDesktopNotification(title string, body string, image string, playSound bool, _ int) error { 10 | notify := gosxnotifier.NewNotification(body) 11 | notify.Title = title 12 | notify.ContentImage = image 13 | 14 | if playSound { 15 | notify.Sound = gosxnotifier.Default 16 | } 17 | 18 | return notify.Push() 19 | } 20 | -------------------------------------------------------------------------------- /internal/notifications/notifications.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "io" 5 | "log/slog" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/ayn2op/discordo/internal/config" 12 | "github.com/ayn2op/discordo/internal/consts" 13 | "github.com/diamondburned/arikawa/v3/discord" 14 | "github.com/diamondburned/arikawa/v3/gateway" 15 | "github.com/diamondburned/ningen/v3" 16 | "github.com/diamondburned/ningen/v3/discordmd" 17 | ) 18 | 19 | func HandleIncomingMessage(s ningen.State, m *gateway.MessageCreateEvent, cfg *config.Config) error { 20 | // Only display notification if enabled and unmuted 21 | if !cfg.Notifications.Enabled || s.MessageMentions(&m.Message) == 0 || cfg.Identify.Status == discord.DoNotDisturbStatus { 22 | return nil 23 | } 24 | 25 | ch, err := s.Cabinet.Channel(m.ChannelID) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | isChannelDM := ch.Type == discord.DirectMessage || ch.Type == discord.GroupDM 31 | guild := (*discord.Guild)(nil) 32 | if !isChannelDM { 33 | guild, err = s.Cabinet.Guild(ch.GuildID) 34 | if err != nil { 35 | return err 36 | } 37 | } 38 | 39 | // Render message 40 | src := []byte(m.Content) 41 | ast := discordmd.ParseWithMessage(src, *s.Cabinet, &m.Message, false) 42 | buff := strings.Builder{} 43 | if err := defaultRenderer.Render(&buff, src, ast); err != nil { 44 | return err 45 | } 46 | 47 | // Handle sent files 48 | notifContent := buff.String() 49 | if m.Content == "" && len(m.Attachments) > 0 { 50 | notifContent = "Uploaded " + m.Message.Attachments[0].Filename 51 | } 52 | 53 | if m.Author.DisplayOrTag() == "" || notifContent == "" { 54 | return nil 55 | } 56 | 57 | notifTitle := m.Author.DisplayOrTag() 58 | if guild != nil { 59 | member, _ := s.Member(ch.GuildID, m.Author.ID) 60 | if member.Nick != "" { 61 | notifTitle = member.Nick 62 | } 63 | 64 | notifTitle = notifTitle + " (#" + ch.Name + ", " + guild.Name + ")" 65 | } 66 | 67 | hash := m.Author.Avatar 68 | if hash == "" { 69 | hash = "default" 70 | } 71 | imagePath, err := getCachedProfileImage(hash, m.Author.AvatarURLWithType(discord.PNGImage)) 72 | if err != nil { 73 | slog.Error("Failed to retrieve avatar image for notification", "err", err) 74 | } 75 | 76 | shouldChime := cfg.Notifications.Sound.Enabled && (!cfg.Notifications.Sound.OnlyOnPing || (isChannelDM || s.MessageMentions(&m.Message) == 3)) 77 | if err := sendDesktopNotification(notifTitle, notifContent, imagePath, shouldChime, cfg.Notifications.Duration); err != nil { 78 | return err 79 | } 80 | 81 | return nil 82 | } 83 | 84 | func getCachedProfileImage(avatarHash discord.Hash, url string) (string, error) { 85 | path, err := os.UserCacheDir() 86 | if err != nil { 87 | return "", err 88 | } 89 | 90 | path = filepath.Join(path, consts.Name, "assets") 91 | if err := os.MkdirAll(path, os.ModePerm); err != nil { 92 | return "", err 93 | } 94 | 95 | path = filepath.Join(path, avatarHash+".png") 96 | if _, err := os.Stat(path); err == nil { 97 | return path, nil 98 | } 99 | 100 | image, err := os.Create(path) 101 | if err != nil { 102 | return "", err 103 | } 104 | defer image.Close() 105 | 106 | resp, err := http.Get(url) 107 | if err != nil { 108 | return "", err 109 | } 110 | defer resp.Body.Close() 111 | 112 | if _, err := io.Copy(image, resp.Body); err != nil { 113 | return "", err 114 | } 115 | 116 | return path, nil 117 | } 118 | -------------------------------------------------------------------------------- /internal/notifications/renderer.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/diamondburned/ningen/v3/discordmd" 7 | "github.com/yuin/goldmark/ast" 8 | gmr "github.com/yuin/goldmark/renderer" 9 | ) 10 | 11 | // Using a modified version of the discordmd BasicRenderer 12 | var defaultRenderer = newRenderer() 13 | 14 | type renderer struct { 15 | config *gmr.Config 16 | } 17 | 18 | func newRenderer() *renderer { 19 | config := gmr.NewConfig() 20 | return &renderer{config} 21 | } 22 | 23 | func (r *renderer) AddOptions(opts ...gmr.Option) { 24 | for _, opt := range opts { 25 | opt.SetConfig(r.config) 26 | } 27 | } 28 | 29 | func (r *renderer) Render(w io.Writer, source []byte, n ast.Node) error { 30 | return ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 31 | switch n := n.(type) { 32 | case *ast.Document: 33 | // noop 34 | case *ast.Blockquote: 35 | io.WriteString(w, "\"") 36 | case *ast.Heading: 37 | io.WriteString(w, "\n") 38 | case *ast.FencedCodeBlock: 39 | io.WriteString(w, "\n") 40 | 41 | if entering { 42 | lines := n.Lines() 43 | for i := range lines.Len() { 44 | line := lines.At(i) 45 | io.WriteString(w, "| ") 46 | w.Write(line.Value(source)) 47 | } 48 | } 49 | case *ast.AutoLink: 50 | if entering { 51 | w.Write(n.URL(source)) 52 | } 53 | case *ast.Link: 54 | if !entering { 55 | io.WriteString(w, " ("+string(n.Destination)+")") 56 | } 57 | case *discordmd.Inline: 58 | if n.Attr&discordmd.AttrSpoiler != 0 { 59 | if entering { 60 | io.WriteString(w, "*spoiler*") 61 | } 62 | return ast.WalkSkipChildren, nil 63 | } 64 | case *ast.Text: 65 | if entering { 66 | w.Write(n.Segment.Value(source)) 67 | switch { 68 | case n.HardLineBreak(): 69 | io.WriteString(w, "\n\n") 70 | case n.SoftLineBreak(): 71 | io.WriteString(w, "\n") 72 | } 73 | } 74 | case *discordmd.Mention: 75 | if entering { 76 | switch { 77 | case n.Channel != nil: 78 | io.WriteString(w, "#"+n.Channel.Name) 79 | case n.GuildUser != nil: 80 | io.WriteString(w, "@"+n.GuildUser.Username) 81 | case n.GuildRole != nil: 82 | io.WriteString(w, "@"+n.GuildRole.Name) 83 | } 84 | } 85 | case *discordmd.Emoji: 86 | if entering { 87 | io.WriteString(w, ":"+string(n.Name)+":") 88 | } 89 | } 90 | 91 | return ast.WalkContinue, nil 92 | }) 93 | } 94 | -------------------------------------------------------------------------------- /internal/ui/util.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/ayn2op/discordo/internal/config" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/rivo/tview" 7 | ) 8 | 9 | func NewConfiguredBox(box *tview.Box, cfg *config.Theme) *tview.Box { 10 | b := cfg.Border 11 | t := cfg.Title 12 | p := b.Padding 13 | box. 14 | SetBorder(cfg.Border.Enabled). 15 | SetBorderColor(tcell.GetColor(b.Color)). 16 | SetBorderPadding(p[0], p[1], p[2], p[3]). 17 | SetTitleAlign(int(t.Align)). 18 | SetFocusFunc(func() { 19 | box.SetBorderColor(tcell.GetColor(b.ActiveColor)) 20 | box.SetTitleColor(tcell.GetColor(t.ActiveColor)) 21 | }). 22 | SetBlurFunc(func() { 23 | box.SetBorderColor(tcell.GetColor(b.Color)) 24 | box.SetTitleColor(tcell.GetColor(t.Color)) 25 | }) 26 | return box 27 | } 28 | 29 | func Centered(p tview.Primitive, width, height int) tview.Primitive { 30 | return tview.NewGrid(). 31 | SetColumns(0, width, 0). 32 | SetRows(0, height, 0). 33 | AddItem(p, 1, 1, 1, 1, 0, 0, true) 34 | } 35 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/ayn2op/discordo/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.Run(); err != nil { 11 | slog.Error("failed to run command", "err", err) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /nix/module-hm.nix: -------------------------------------------------------------------------------- 1 | self: { options, config, lib, pkgs, ... }: 2 | let 3 | cfg = config.programs.discordo; 4 | settingsFormat = pkgs.formats.toml { }; 5 | in 6 | { 7 | options.programs.discordo = { 8 | enable = lib.mkEnableOption "discordo"; 9 | package = lib.mkPackageOption self.packages.${pkgs.system} "discordo" { }; 10 | settings = lib.mkOption { 11 | type = settingsFormat.type; 12 | description = '' 13 | Configuration for discordo. 14 | See https://github.com/ayn2op/discordo?tab=readme-ov-file#configuration 15 | for available options and default values. 16 | ''; 17 | default = { }; 18 | }; 19 | }; 20 | config = lib.mkIf cfg.enable { 21 | home.packages = [ cfg.package ]; 22 | xdg.configFile."discordo/config.toml".source = settingsFormat.generate 23 | "discordo-config.toml" 24 | cfg.settings; 25 | }; 26 | } 27 | 28 | 29 | -------------------------------------------------------------------------------- /nix/package.nix: -------------------------------------------------------------------------------- 1 | { discordo 2 | , lib 3 | }: discordo.overrideAttrs { 4 | version = "git"; 5 | 6 | src = let fs = lib.fileset; in fs.toSource { 7 | root = ../.; 8 | fileset = fs.unions [ 9 | ../go.mod 10 | ../go.sum 11 | ../main.go 12 | ../cmd 13 | ../internal 14 | ]; 15 | }; 16 | 17 | vendorHash = "sha256-Q9ROPLRP8HSx4P30bSdX30qB2Q1oERz+gZ7Tb23oXbI="; 18 | } 19 | --------------------------------------------------------------------------------