├── .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 · [](https://discord.com/invite/VzF9UFn2aB) [](https://github.com/ayn2op/discordo/actions/workflows/ci.yml) [](https://goreportcard.com/report/github.com/ayn2op/discordo) [](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 | 
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 |
--------------------------------------------------------------------------------