pax_global_header00006660000000000000000000000064136344203430014514gustar00rootroot0000000000000052 comment=cf383c5aa9f17d5cca1c374d63b099742c8582d4 bitlbee-mastodon-1.4.4/000077500000000000000000000000001363442034300147525ustar00rootroot00000000000000bitlbee-mastodon-1.4.4/.gitignore000066400000000000000000000003131363442034300167370ustar00rootroot00000000000000*~ *.la *.lo *.o *.tar.* .deps .libs aclocal.m4 autom4te.cache build-aux config.log config.status configure debian INSTALL libtool libtool.m4 lt*.m4 Makefile Makefile.in config.h* stamp-h1 TAGS src/TAGS bitlbee-mastodon-1.4.4/HISTORY.md000066400000000000000000000037651363442034300164500ustar00rootroot00000000000000# The History of this Plugin New features for 1.4.4: - fix list management command - figure out username and instance URL from account name New features for 1.4.3: - direct messages are now threaded correctly - search works for Mastodon 3.0.0 instances New features for 1.4.2: - 1.4.1 got mistagged, so this tag makes sure it all syncs up again New features for 1.4.1: - small improvements to command parsing and feedback - better handling of multiple mentions - better handling of direct messages - possible fix to a crash related to filters New features for 1.4.0: - new `filter` command - new `list` command - new settings to hide notifications by type Incompatible change in 1.4.0: If you have subscribed to a hashtag, you need to change your channel settings and prepend the hash. Without the hash, the plugin gets confused and things the channel is for a list of the same name. Do this from the control channel (&bitlbee). Let's assume you have a channel called #hashtag. It's *room* setting should be #hashtag. If it's lacking the initial hash: > **<kensanata>** channel #hashtag set room > **<root>** room = `hashtag' > **<kensanata>** channel #hashtag set room #hashtag > **<root>** room = `#hashtag' There, fixed it. New features for 1.3.1: - new `visibility` command - new `cw` command - removed support for posting a content warning using CW1 New features for 1.2.0: - format search results - new `bio` command - new `pinned` command - add all the accounts when replying - fixed list of accounts in the channel when connecting New features for 1.1.0: - new `hide_sensitive` setting # Beginnings This plugin started out as a fork of Bitlbee itself with the Mastodon code being based on a copy of the Twitter code. When it became clear that my code just wasn't going to get merged, I took a look at the Facebook and Discord plugins for Bitlbee and decided that it should be easy to turn my existing code into a plugin. Luckily, that worked as intended. – Alex Schroeder bitlbee-mastodon-1.4.4/LICENSE000066400000000000000000000432541363442034300157670ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. bitlbee-mastodon-1.4.4/Makefile.am000066400000000000000000000013701363442034300170070ustar00rootroot00000000000000# Copyright 2016 Artem Savkov # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . AUTOMAKE_OPTIONS = foreign ACLOCAL_AMFLAGS = -Im4 SUBDIRS = src doc bitlbee-mastodon-1.4.4/README.md000066400000000000000000000260351363442034300162370ustar00rootroot00000000000000Mastodon plugin for Bitlbee =========================== This plugin allows [Bitlbee](https://www.bitlbee.org/) to communicate with [Mastodon](https://joinmastodon.org/) instances. Mastodon is a free, open-source, decentralized microblogging network. Bitlbee is an IRC server connecting to various other text messaging services. You run Bitlbee and connect to it using an IRC client, then configure Bitlbee to connect to other services, such as a Mastodon instance where you already have an account. The benefit is that you can now use [any IRC client](https://en.wikipedia.org/wiki/Comparison_of_Internet_Relay_Chat_clients) you want to connect to Mastodon. ![A screenshot of Emacs running the rcirc IRC client connected to a Mastodon instance via Bitlbee](pics/screenshot.png) Please report issues using the [Software Wiki](https://alexschroeder.ch/software/Bitlbee_Mastodon). For questions, ping **kensanata** on `irc.oftc.net/#bitlbee`. **Table of Contents** - [License](#license) - [Usage](#usage) - [Build dependencies](#build-dependencies) - [Building and Installing](#building-and-installing) - [Bugs](#bugs) - [Debugging](#debugging) License ------- Most of the source code is distributed under the [GNU Lesser Public License 2.1](https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html#SEC1). The build system is distributed under the [GNU Public License 2.0 or any later version](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html#SEC1). Anything without an obvious license in the file header also uses the GPL 2.0 or any later version. Contributors ------------ * [velartrill](http://github.com/velartrill) Usage ----- First, make sure the installation worked by checking the installed protocols using the `plugins` command in your `&bitlbee` control channel. If this worked, create your account using the `account` command in your `&bitlbee` control channel. In this example, we'll sign in as **@kensanata@mastodon.weaponvsac.space**. This assumes an existing account on an instance! Replace username and Mastodon server when trying it. In your **&bitlbee** channel, add a new account, change it's **base_url** to point at your instance, and switch it on: > **<kensanata>** account add mastodon kensanata@mastodon.weaponvsac.space > **<root>** Account successfully added with tag mastodon > **<kensanata>** account mastodon on > **<root>** mastodon - Logging in: Login > **<root>** mastodon - Logging in: Parsing application registration response > **<root>** mastodon - Logging in: Starting OAuth authentication At this point, you'll get contacted by the user **mastodon_oauth** with a big URL that you need to visit using a browser. Visit the URL and authenticate the client. You'll get back another very long string. Copy and paste this string: > **<mastodon_oauth>** Open this URL in your browser to authenticate: https://....... > **<mastodon_oauth>** Respond to this message with the returned authorization token. > **<kensanata>** \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* Once you do that, your login should complete in the **&bitlbee** channel: > **<root>** mastodon2 - Logging in: Requesting OAuth access token > **<root>** mastodon2 - Logging in: Connecting > **<root>** mastodon2 - Logging in: Verifying credentials > **<root>** mastodon2 - Logging in: Getting home timeline > **<root>** mastodon2 - Logging in: Logged in You should now have a channel called **#mastodon.weaponsvsac.space@localhost** where all the status updates and notifications get shown. We'll call this your **account channel**. See **help set name** to change it's name. Mastodon gives BitlBee a permanent authentication token, which is saved in your configuration. You should probably save this configuration. > **<kensanata>** save > **<root>** Configuration saved Adding the account and switching it on loads the Bitlbee Mastodon help file into the system, allowing you to use `help mastodon` in your `&bitlbee` control channel. Alternatively, a snapshot of the entries added to the help system by this plugin are available on the [help page](https://alexschroeder.ch/cgit/bitlbee-mastodon/tree/doc/HELP.md#top). Build dependencies ------------------ - `bitlbee` and headers >= 3.5 If you haven't built Bitlbee yourself you will need to install the dev package, usually `bitlbee-dev` or `bitlbee-devel`. If Bitlbee was built from source don't forget to do `make install-dev`. To NetBSD users: your Bitlbee doesn't include the devel files. One way to fix this is to build Bitlbee via `pkgsrc`. You'll need to add to the `chat/bitlbee` pkgsrc `Makefile`, in the `post-build` hook, this line: ``` cd ${WRKSRC} && ${GMAKE} DESTDIR=${DESTDIR} install-dev ``` Don't forget to regenerate your `PLIST` (`plugindir` is `/usr/pkg/lib/bitlbee`) with: ``` make print-PLIST > PLIST ``` - `glib2` and headers => 2.32 The library itself is usually installed as a dependency of Bitlbee but headers need to be installed separately. In Debian, the package containing them is `libglib2.0-dev`. - `autotools` (if building from git) A bit of an overkill, but it works. If you don't have this package, try looking for `autoconf` and `libtool`. \*BSD users should install `autoconf`, `automake` and `libtool`, preferably the latest version available. FreeBSD will also need `pkgconfig` on top of that. GNU `sed` (gsed), GNU `make` (gmake), and the `bash` shell are also required -- BSD `make` cannot successfully build bitlbee-mastodon, and the build process uses GNU extensions to both the Bourne shell and `sed`. Building and Installing ----------------------- Check your distribution: - FreeBSD: `irc/bitlbee-mastodon` Alternatively, build it from source. You need to generate the autotools configuration script and related files by executing the following command: ``` ./autogen.sh ``` After that, you can build as usual: ``` ./configure make sudo make install ``` 🔥 If your Bitlbee's plugindir is in a non-standard location you need to specify it: `./configure with --with-plugindir=/path/to/plugindir` 🔥 If you're installing this plugin in a system where you didn't build your own Bitlbee but installed revision 3.5.1 (e.g. on a Debian system around the end of 2017), you will run into a problem: the plugin will get installed into `/usr/lib/bitlbee` (`plugindir`) but the documentation wants to install into `/usr/local/share/bitlbee` instead of `/usr/share/bitlbee` (`datadir`). As you can tell from `/usr/lib/pkgconfig/bitlbee.pc`, there is no `datadir` for you. In this situation, try `./configure --prefix=/usr` and build and install again. Bugs ---- 🔥 [Crashing while running Twitter](https://alexschroeder.ch/software/Crashing_while_running_Twitter): there seems to be some sort of interaction between the Twitter code and the Mastodon plugin. If you get connected to Mastodon and then Bitlbee crashes, and you have a Twitter account set up, try this: 1. take the Twitter account offline 2. take the Mastodon account online 3. take the Twitter account online 🔥 [Cannot use Pleroma](https://alexschroeder.ch/software/Support_Websockets_for_Streaming): there are two ways to do streaming for Mastodon: regular long-running HTTP requests, or a bunch of websockets that provides all the streaming info. Sadly, the Mastodon plugin only supports HTTP streaming and Pleroma only supports websockets. But we're [working on it](https://github.com/kensanata/bitlbee-mastodon/pull/43). 🔥 **No support for 2FA**: the Mastodon plugin knows about OAuth, which means it doesn't ask you for the password of your Mastodon account. Instead, it gives you an URL on your instance where you identify yourself and get back a token which you then give the Mastodon plugin. You can revoke this token from your instance by going to Preferences → Account → Authorized Apps and looking for Bitlbee. Sadly, the Mastodon plugin doesn't know about 2FA (two-factor auth). Debugging --------- Before debugging Bitlbee, you probably need to stop the system from running Bitlbee. The problem is that `systemd` was instructed not to kill it (`KillMode=process`). Therefore I run the following: ``` sudo systemctl stop bitlbee sudo killall bitlbee ``` You can enable extra debug output for `bitlbee-mastodon` by setting the `BITLBEE_DEBUG` environment variable. This will print all traffic it exchanges with Mastodon servers to STDOUT and there is a lot of it. You can see this output in the systemd journal: ``` sudo journalctl -fu bitlbee ``` Alternatively, run `bitlbee` in foreground mode: ``` BITLBEE_DEBUG=1 bitlbee -nvD ``` If you run this as a normal use, bitlbee cannot read its config file and thus won't know about your existing accounts. If you need to read your config file from the standard location, run it as the bitlbee user: ``` BITLBEE_DEBUG=1 sudo -u bitlbee bitlbee -nvD ``` If you need to use a debugger, make a copy of `/etc/bitlbee/bitlbee.conf` (or simply create an empty file) and `/var/lib/bitlbee/`. Then run `gdb`, set the breakpoints you want (answer yes to "Make breakpoint pending on future shared library load?"), and run it using the options shown: ``` touch bitlbee.conf sudo cp /var/lib/bitlbee/*.xml . gdb bitlbee b mastodon_post_message y run -nvD -c bitlbee.conf -d . ``` Then connect with an IRC client as you usually do. If you're getting error messages about the address being in use, you haven't managed to kill the existing Bitlbee. ``` Error: bind: Address already in use ``` Check who's listening on port 6667: ``` sudo lsof -i:6667 ``` Then do what is necessary to kill it. 😈 Note that perhaps you must remove the `-O2` from `CFLAGS` in the `src/Makefile` and run `make clean && make && sudo make install` in the `src` directory in order to build and install the module without any compiler optimisation. If you run `make` in the top directory, `src/Makefile` will get regenerated and you'll get your optimized code again. You know you're running optimized code when things seem to repeat themselves in strange ways: ``` (gdb) n 594 if (!mastodon_length_check(ic, message)) { (gdb) 583 { (gdb) 594 if (!mastodon_length_check(ic, message)) { (gdb) 584 struct mastodon_data *md = ic->proto_data; (gdb) 594 if (!mastodon_length_check(ic, message)) { (gdb) 584 struct mastodon_data *md = ic->proto_data; (gdb) 594 if (!mastodon_length_check(ic, message)) { (gdb) ``` Or when values can't be printed: ``` (gdb) p m->str value has been optimized out ``` WARNING: there *is* sensitive information in this debug output, such as auth tokens, your plaintext password and, obviously, your incoming and outgoing messages. Be sure to remove any information you are not willing to share before posting it anywhere. If you are experiencing crashes please refer to [debugging crashes](https://wiki.bitlbee.org/DebuggingCrashes) for information on how to get a meaningful backtrace. bitlbee-mastodon-1.4.4/RELEASE.md000066400000000000000000000003621363442034300163550ustar00rootroot00000000000000# How to prepare a release 1. verify that `mastodon news` in `mastodon-help.txt` is up to date 2. change the version number in `configure.ac` 3. update `HISTORY.md` 4. commit it all 5. tag the commit 6. push the tag to `origin` and `github` bitlbee-mastodon-1.4.4/autogen.sh000066400000000000000000000013611363442034300167510ustar00rootroot00000000000000#!/bin/sh # Copyright 2016 Artem Savkov # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . mkdir -p m4 autoreconf --verbose --force --install bitlbee-mastodon-1.4.4/configure.ac000066400000000000000000000050501363442034300172400ustar00rootroot00000000000000# Copyright 2016 Artem Savkov # Copyright 2017-2019 Alex Schroeder # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . AC_INIT( [bitlbee-mastodon], [1.4.4], [https://github.com/kensanata/bitlbee-mastodon/issues], [bitlbee-mastodon], [https://alexschroeder.ch/cgit/bitlbee-mastodon/about/], ) AC_CONFIG_AUX_DIR([build-aux]) AC_CONFIG_MACRO_DIR([m4]) AM_INIT_AUTOMAKE([no-define]) AC_PROG_CC AM_PROG_CC_C_O AC_DISABLE_STATIC AC_PROG_LIBTOOL m4_ifdef([AM_SILENT_RULES], [AM_SILENT_RULES([yes])]) m4_ifdef([AC_PROG_CC_C99], [AC_PROG_CC_C99]) # Define PKG_CHECK_VAR() for pkg-config < 0.28 m4_define_default( [PKG_CHECK_VAR], [AC_ARG_VAR([$1], [value of $3 for $2, overriding pkg-config]) AS_IF([test -z "$$1"], [$1=`$PKG_CONFIG --variable="$3" "$2"`]) AS_IF([test -n "$$1"], [$4], [$5])] ) # Checks for libraries. PKG_CHECK_MODULES([BITLBEE], [bitlbee >= 3.5]) PKG_CHECK_MODULES([GLIB], [glib-2.0 >= 2.32]) AC_CONFIG_HEADERS([config.h]) # Checks for typedefs, structures, and compiler characteristics. AC_TYPE_SIZE_T # Checks for library functions. AC_CHECK_FUNCS([memset]) # bitlbee-specific stuff AC_ARG_WITH([plugindir], [AS_HELP_STRING([--with-plugindir], [BitlBee plugin directory])], [plugindir="$with_plugindir"] ) AS_IF( [test -z "$plugindir"], [PKG_CHECK_VAR( [BITLBEE_PLUGINDIR], [bitlbee], [plugindir], [plugindir="$BITLBEE_PLUGINDIR"], [plugindir="$libdir/bitlbee"] )] ) AC_SUBST([plugindir]) AC_ARG_WITH([bdatadir], [AS_HELP_STRING([--with-bdatadir], [BitlBee data directory])], [bdatadir="$with_bdatadir"] ) AS_IF( [test -z "$bdatadir"], [PKG_CHECK_VAR( [BITLBEE_DATADIR], [bitlbee], [datadir], [datadir="$BITLBEE_DATADIR"], [datadir="$datarootdir/bitlbee"] )], [datadir="$bdatadir"] ) AC_SUBST([datadir]) AC_SUBST([ac_cv_path_SED]) AC_CONFIG_FILES([Makefile src/Makefile doc/Makefile]) AC_OUTPUT bitlbee-mastodon-1.4.4/doc/000077500000000000000000000000001363442034300155175ustar00rootroot00000000000000bitlbee-mastodon-1.4.4/doc/HELP.md000066400000000000000000000653621363442034300166050ustar00rootroot00000000000000# Bitlbee Mastodon This document was generated from the help text for the plugin. Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. * *[news](#news)* - Backwards incompatible changes * *[register](#register)* - Registering an account * *[connect](#connect)* - Connecting to an instance * *[read](#read)* - Reading your timeline * *[post](#post)* - Posting a new status * *[undo](#undo)* - Undo and redo * *[dm](#dm)* - Sending direct messages * *[context](#context)* - Showing a status in its context * *[reply](#reply)* - Replying to a status * *[delete](#delete)* - Deleting a status * *[favourite](#favourite)* - Favouring a status * *[follow](#follow)* - Following an account * *[block](#block)* - Blocking an account * *[mute](#mute)* - Muting an account * *[boost](#boost)* - Boosting a status * *[more](#more)* - Getting more information about things * *[search](#search)* - Searching for accounts and hashtags * *[spam](#spam)* - Reporting a status * *[control](#control)* - Commands in the control channel * *[hashtag](#hashtag)* - Showing and following a hashtag * *[public](#public)* - Showing and following the local or federated timeline * *[lists](#lists)* - Managing lists * *[filters](#filters)* - Managing filters * *[notifications](#notifications)* - Showing your notifications * *[set](#set)* - Settings affecting Mastodon accounts ## news Use **plugins** in the control channel (**&bitlbee**) to learn which version of the plugin you have. Incompatible change in **1.4.3**: The URL path is ignored in the base URL (such that we can use /api/v2/search). Incompatible change in **1.4.0**: If you have subscribed to a hashtag, you need to change your channel settings and prepend the hash. Do this from the control channel (**&bitlbee**). Let's assume you have a channel called #hashtag. It's **room** setting should be **#hashtag**. If it's lacking the initial hash: > **<kensanata>** channel #hashtag set room > **<root>** room = `hashtag' > **<kensanata>** channel #hashtag set room #hashtag > **<root>** room = `#hashtag' Don't forget to save your settings. ## register You need to register your Mastodon account on an **instance**. See https://instances.social/ if you need help picking an instance. It's a bit like picking a mail server and signing up. Sadly, there is currently no way to do this from IRC. Your need to use a web browser to do it. Once you have the account, see **help account add mastodon** for setting up your account. ## set These settings will affect Mastodon accounts: * **set auto_reply_timeout** - replies to most recent messages in the last 3h * **set base_url** - URL for your Mastodon instance's API * **set commands** - extra commands available in Mastodon channels * **set message_length** - limit messages to 500 characters * **set mode** - create a separate channel for contacts/messages * **set show_ids** - display the "id" in front of every message * **set target_url_length** - an URL counts as 23 characters * **set name** - the name for your account channel * **set hide_sensitive** - hide content marked as sensitive * **set sensitive_flag** - text to flag sensitive content with * **set visibility** - default post privacy * **set hide_boosts** - hide notifications of people boosting * **set hide_favourites** - hide notifications of favourites * **set hide_follows** - hide notifications of follows * **set hide_mentions** - hide notifications of mentions Use **help** to learn more about these options. ## set name > **Type:** string > **Scope:** account > **Default:** empty Without a name set, Mastodon accounts will use host URL and acccount name to create a channel name. This results in a long channel name and if you prefer a shorter channel name, use this setting (when the account is offline) to change it. > **<kensanata>** account mastodon off > **<kensanata>** account mastodon set name masto > **<kensanata>** account mastodon on > **<kensanata>** save ## set hide_sensitive > **Type:** boolean > **Scope:** account > **Default:** false > **Possible Values:** true, false, rot13, advanced_rot13 By default, sensitive content (content behind a content warning) is simply shown. The content warning is printed, and then the sensitive content is printed. > **<somebody>** [27] [CW: this is the warning] \*NSFW\* this is the text If you set this variable in the control channel (**&bitlbee**), sensitive content is not printed. Instead, you'll see "[hidden: <the URL>]". If you still want to read it, visit the URL. > **<kensanata>** account mastodon set hide_sensitive true > **<kensanata>** save Don't forget to save your settings. The result: > **<somebody>** [27] [CW: this is the warning] \*NSFW\* [hidden: https://social.nasqueron.org/@kensanata/100133795756949791] Additionally, when using **rot13**: > **<somebody>** [27] [CW: this is the warning] \*NSFW\* guvf vf gur grkg And when using **advanced_rot13**: > **<somebody>** [27] [CW: this is the warning] \*NSFW\* > **<somebody>** CW1 guvf vf gur grkg All sensitive content is also marked as Not Safe For Work (NSFW) and flagged as such. You can change the text using an option, see **help set sensitive_flag**). ## set sensitive_flag > **Type:** string > **Scope:** account > **Default:** "\*NSFW\* " This is the text to flag sensitive content with. You can change this setting in the control channel (**&bitlbee**). The default is Not Safe For Work (NSFW). If you wanted to simply use red for the sensitive content, you could use "^C5", for example. Be sure to use an actual Control-C, here. This might be challenging to enter, depending on your IRC client. Sadly, that's how it goes. For more information, see https://www.mirc.com/colors.html. ## set visibility > **Type:** string > **Scope:** account > **Default:** "public" This is the default visibility of your toots. There are three valid options: "public" (everybody can see your toots), "unlisted" (everybody can see your toots but they are not found on the public timelines), and "private" (only followers can see them). For more information see https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md#toot-privacy. This is used whenever you make a new post. You can override this visibility for new posts in the control channel (**&bitlbee**). See *[post](#post)* for more. > **<kensanata>** account mastodon set visibility private > **<kensanata>** save Don't forget to save your settings. ## set hide_boosts > **Type:** boolean > **Scope:** account > **Default:** false > **Possible Values:** true, false By default, you are shown a notification when somebody boosts a status of yours. This setting allows you to turn it off in the control channel (**&bitlbee**). > **<kensanata>** account mastodon set hide_boosts true > **<kensanata>** save Don't forget to save your settings. ## set hide_favourites > **Type:** boolean > **Scope:** account > **Default:** false > **Possible Values:** true, false By default, you are shown a notification when somebody favourites a status of yours. This setting allows you to turn it off in the control channel (**&bitlbee**). > **<kensanata>** account mastodon set hide_favourites true > **<kensanata>** save Don't forget to save your settings. ## set hide_follows > **Type:** boolean > **Scope:** account > **Default:** false > **Possible Values:** true, false By default, you are shown a notification when somebody follows you. This setting allows you to turn it off in the control channel (**&bitlbee**). > **<kensanata>** account mastodon set hide_follows true > **<kensanata>** save Don't forget to save your settings. ## set hide_mentions > **Type:** boolean > **Scope:** account > **Default:** false > **Possible Values:** true, false By default, you are shown a notification when somebody mentions you. This setting allows you to turn it off in the control channel (**&bitlbee**). > **<kensanata>** account mastodon set hide_mentions true > **<kensanata>** save Don't forget to save your settings. ## account add mastodon > **Syntax:** account add mastodon <handle> By default all the Mastodon accounts you are following will appear in a new channel named after your Mastodon instance. You can change this behaviour using the **mode** setting (see **help set mode**). To send toots yourself, just write in the groupchat channel. Since Mastodon requires OAuth authentication, you should not enter your Mastodon password into BitlBee. The first time you log in, BitlBee will start OAuth authentication. (See **help set oauth**.) In order to connect to the correct instances, you must most probably change the **base_url** setting. See *[connect](#connect)* for an example. ## connect In this section, we'll sign in as **@kensanata@mastodon.weaponvsac.space**. This section assumes an existing account on an instance! Replace username and Mastodon server when trying it. In your **&bitlbee** channel, add a new account, change it's **base_url** to point at your instance, and switch it on: > **<kensanata>** account add mastodon @kensanata > **<root>** Account successfully added with tag mastodon > **<kensanata>** account mastodon set base_url https://mastodon.weaponvsac.space > **<root>** base_url = `https://mastodon.weaponvsac.space > **<kensanata>** account mastodon on > **<root>** mastodon - Logging in: Login > **<root>** mastodon - Logging in: Parsing application registration response > **<root>** mastodon - Logging in: Starting OAuth authentication At this point, you'll get contacted by the user **mastodon_oauth** with a big URL that you need to visit using a browser. See *[connect2](#connect2)* for the OAuth authentication. ## connect2 Visit the URL the **mastodon_oauth** user gave you and authenticate the client. You'll get back another very long string. Copy and paste this string: > **<mastodon_oauth>** Open this URL in your browser to authenticate: https://....... > **<mastodon_oauth>** Respond to this message with the returned authorization token. > **<kensanata>** \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* Once you do that, your login should complete in the **&bitlbee** channel: > **<root>** mastodon2 - Logging in: Requesting OAuth access token > **<root>** mastodon2 - Logging in: Connecting > **<root>** mastodon2 - Logging in: Verifying credentials > **<root>** mastodon2 - Logging in: Getting home timeline > **<root>** mastodon2 - Logging in: Logged in You should now have a channel called **#mastodon.weaponsvsac.space@localhost** where all the status updates and notifications get shown. We'll call this your **account channel**. See **help set name** to change it's name. Mastodon gives BitlBee a permanent authentication token, which is saved in your configuration. You should probably save this configuration. > **<kensanata>** save > **<root>** Configuration saved ## read By default, you are connected to a channel showing your "home" timeline. You can use use the command **timeline home** to redisplay it, followed by **more** to go futher back in time. See *[public](#public)* for the other timelines. The default **mode** setting is **chat**. This means that each Mastodon account you add results in a new channel in your IRC client. Use **help set mode** in your Bitlbee control channel (**&bitlbee**) to read up on different modes. ## post The default **commands** setting is **true**. This means that anything you type is a toot unless it looks like command, in which case it is handled as such. In addition to that, you can use the **post <message>** command. If you set the **commands** setting to **strict**, using the **post** command is mandatory. Use **help set commands** in your Bitlbee control channel (**&bitlbee**) to read up on the various commands. New posts get the default visibility: "public". See **help set visibility** for more. You can change the visibility of the next toot by using the **visibility** command. > **<kensanata>** visibility unlisted > **<root>** Next post is unlisted > **<kensanata>** Hello! > **<root>** You, unlisted: [06] Hello! Use **cw <content warning>** to set a content warning for your next reply or post. > **<kensanata>** cw capitalism > **<root>** Next post will get content warning 'capitalism' > **<kensanata>** No! Just no! > **<root>** You: [09] [CW: capitalism] \*NSFW\* No! Just no! When mentioning people in your toots, make sure to qualify them appropriately. > **<somebody>** I'm using @kensanata@octodon.social's Mastodon plugin for Bitlbee. By default the Mastodon server limits your toots to 500 characters. Bitlbee tries to compute the message length based on the various Mastodon rules and prevents you from posting longer messages. Use **help set message_length** in your Bitlbee control channel (**&bitlbee**) to read up on the hairy details. Basically, some aspects of of your message will count for less: URLs, domain names for mentioned user accounts and the like. See **help set target_url_length** for more information on how URLs are counted. Note also that Bitlbee itself does word-wrapping to limit messages to 425 characters. That is why longer messages may look like extra newlines have been introduced but if you check the status on the web, you'll see that everything is OK. ## undo Use **undo** and **redo** to undo and redo recent commands. Bitlbee will remember your last 10 Mastodon commands and allows you to undo and redo them. Use **history** to see the list of commands you can undo. There is a pointer (**>**) showing the current position. Use **history undo** if you are interested in seeing the commands that will be used to undo what you just did. ## favourite Use **fav <id|nick>** to favour a status or the last status by a nick. Synonyms: **favourite**, **favorite**, **like**. Use **unfav <id|nick>** to unfavour a status or the last status by a nick. Synonyms: **unfavourite**, **unfavorite**, **unlike**, **dislike**. ## dm There are two ways to send a direct message to someone (also known as a DM). If you are following them, then there exists a handle for them in your IRC client and you can simply **/msg <nick>** To DM anyone regardless of whether you follow them, use **visibility direct** and then write a post that mentions their long @nick@server account name. See **help set visibility** for more. ## context Use **context <id|nick>** to show some context for a status or the last status by a nick. This will display the ancestors and descendants of a status. Use **timeline <nick>** to show the most recent messages by a nick. Use **more** to show more statuses from the same command. Use **timeline @<account>** to show the most recent messages by an account that isn't a nick in your channel. Use **more** to show more statuses from the same command. ## reply If you use the default IRC conventions of starting a message with a nickname and a colon (**:**) or a comma (**,**), then your message will be treated as a reply to that nick's last message. As is custom, the recipient and all the people they mentioned in their toot get mentioned in your reply. This only works if that nick's last message was sent within the last 3h. For more information about this time window use **help set auto_reply_timeout** in your Bitlbee control channel (**&bitlbee**). You can also reply to an earlier message by referring to its id using the **reply <id> <message>** command. Again, the recipient and all the people they mentioned in their toot get mentioned in your reply. If you set the **commands** setting to **strict**, using the **reply** command is mandatory. When replying to a post, your reply uses the same visibility as the original toot unless your default visibility is more restricted: direct > private > unlisted > public. See **help set visibility** for more. You can set a different visibility using the **visibility** command. See *[post](#post)* for more. When replying to a post, your reply uses the same content warning unless you have set up a different one via **cw**. See *[post](#post)* for more. ## delete Use **del <id>** to delete a status or your last status. Synonym: **delete**. ## favourite Use **fav <id|nick>** to favour a status or the last status by a nick. Synonyms: **favourite**, **favorite**, **like**. Use **unfav <id|nick>** to unfavour a status or the last status by a nick. Synonyms: **unfavourite**, **unfavorite**, **unlike**, **dislike**. ## follow Use **follow <nick|account>** to follow somebody. This determines the nicks in your channel. Verify the list using **/names**. Usually you'll be providing a local or remote account to follow. In the background, Bitlbee will run a search for the account you provided and follow the first match. Sometimes there will be nicks in the channel which you are not following, e.g. a nick is automatically added to the channel when a status of theirs mentioning you is shown. Use **unfollow <nick>** to unfollow a nick. Synonyms: **allow**. ## block Use **block <nick>** to block a nick on the server. This is independent of your IRC client's **/ignore** command, if available. Use **unblock <nick>** to unblock a nick. ## mute Use **mute user <nick>** to mute a nick on the server. Use **unmute user <nick>** to unmute a nick. Use **mute <id|nick>** to mute the conversation based on a status or the last status by a nick. Muting a status will prevent replies to it, favourites and replies of it from appearing. Use **unmute <id|nick>** to unmute the conversation based on a status or the last status by a nick. ## boost Use **boost <id|nick>** to boost a status or the last status by a nick. Use **unboost <id|nick>** to unboost a status or the last status by a nick. ## more Use **url <id|nick>** to get the URL to a status or the last status by a nick. Use **whois <id|nick>** to show handle and full name by a nick, or of all the nicks mentioned in a status. Use **bio <nick>** to show the bio of a nick. Use **pinned <nick>** to show the pinned statuses of a nick. Use **pin <id>** to pin a status to your profile and use **unpin <id>** to unpin a status from your profile. Use **info instance** to get debug information about your instance. Use **info user <nick|account>** to get debug information about an account. Use **info relation <nick|account>** to get debug information about the relation to an account. Use **info <id|nick>** to get debug information about a status or the last status by a nick. Use **api <get|put|post|delete> <endpoint> <optional args>** to call the API directly. > **<kensanata>** api get /lists > **<root>** { > **<root>** id: 5 > **<root>** title: Bots > **<root>** } > **<root>** { > **<root>** id: 9 > **<root>** title: OSR > **<root>** } > **<kensanata>** api delete /lists/5 > **<kensanata>** api get /lists > **<root>** { > **<root>** id: 9 > **<root>** title: OSR > **<root>** } > **<kensanata>** api post /lists/9/accounts account_ids[] 13250 ## search Mastodon allows you to search for accounts, hashtags, and statuses you've written, boosted, favourited or were mentioned in, if your instance has this feature enabled. Use **search <what>** to search for all these things. You can also search for a specific status by searching the URL of said status. This sounds strange but it will allow you to boost it, for example. If you want to show the statuses for a specific account or a hashtag, use **timeline <nick|#hashtag>**. If you want to subscribe to a particular hashtag, see *[hashtag](#hashtag)* for more. ## spam Use **report <id|nick> <comment>** to report a status or the last status by a nick. Synonyms:**spam**. Note that the comment is mandatory. Explain why the status is being reported. The administrator of your instance will see this report and decide what to do about it, if anything. ## control As we said at the beginning, the default **mode** setting is **chat**. This means that each Mastodon account you add will result in a new channel in your IRC client. All the commands mentioned above are what you type in this "instance channel." There are some standard root commands that only work in the control channel, **&bitlbee**. ## hashtag Use **timeline #<hashtag>** to show the most recent messages for a hashtag. Use **more** to show more statuses from the same command. If you want to follow a hashtag, you need to use the control channel, **&bitlbee**. Here's how to subscribe to **#hashtag** for the account **mastodon**: > **<kensanata>** chat add mastodon #hashtag > **<kensanata>** channel #hashtag set auto_join true > **<kensanata>** /join #hashtag Don't forget to **save** your config. Use **channel list** to see all the channels you have available. See **help chat add** for more information. Note that where as you can still issue commands in these hashtag channels, the output is going to appear in the original **account channel**. If you try to subsribe to the same hashtag on a different instance, you'll run into a problem. The solution is to use a different name for the channel and then use **channel <channel> set room #hashtag**. > **<kensanata>** chat add mastodon2 #hashtag > **<root>** A channel named `#hashtag already exists... > **<kensanata>** chat add mastodon2 #hashtag2 > **<kensanata>** channel #hashtag2 set room #hashtag > **<kensanata>** channel #hashtag2 set auto_join true > **<kensanata>** /join #hashtag2 ## public Use **timeline local** to show the most recent messages for the local timeline (these are statuses from accounts on your instance). Use **timeline federated** to show the most recent messages for the federated timeline (these are statuses from the local accounts and anybody they are following). Use **more** to show more statuses from the same command. If you want to follow a hashtag, or the local, or the feredated timeline, you need to use the control channel, **&bitlbee**. The following assumes that your account is called **mastodon**. The **chat add** command takes the parameters **account**, **timeline**, and **channel name**. In the example we're giving the channel a similar name. You can name the channel whatever you want. The important part is that the channel **topic** must be the name of the timeline it is subscribing to. > **<kensanata>** chat add mastodon local #local > **<kensanata>** channel #local set auto_join true > **<kensanata>** /join #local Or: > **<kensanata>** chat add mastodon federated #federated > **<kensanata>** channel #federated set auto_join true > **<kensanata>** /join #federated Don't forget to **save** your config. Note that where as you can still issue commands in these channels, the output is going to appear in the original **account channel**. ## lists You can make lists of the people you follow. To read the latest messages of people in a list, use **timeline <title>**. If you then create a separate channel for a list, you'll see only statuses of people in that list. These are the commands available: > **list** (to see your lists) > **list create <title>** > **list delete <title>** > **list <title>** (to see the accounts in a list) > **list add <nick> to <title>** > **list remove <nick> from <title>** > **list reload** (when you are done changing list memberships) > **timeline <title>** (to read statuses from these accounts) Example: > **<somebody>** follow kensanata@octodon.social \*\*\* kensanata JOIN > **<root>** You are now following kensanata. > **<somebody>** list create Important people > **<root>** Command processed successfully > **<somebody>** list add kensanata to Important people > **<root>** Command processed successfully If you want to follow a list, you need to use the control channel, **&bitlbee**. The following assumes that your account is called **mastodon**. The **chat add** command takes the parameters **account**, **title**, and **channel name**. As list titles may contain spaces, you need to use quotes. You can name the channel whatever you want. The important part is that the channel **topic** must be the title of a list. > **<kensanata>** chat add mastodon "Important people" #important > **<kensanata>** channel #important set auto_join true > **<kensanata>** /join #important Don't forget to **save** your config. ## filters You can create filters which remove toots from the output. These are the commands available: > **filter** (to load and see your filters) > **filter create <phrase>** > **filter delete <n>** Currently filters are created for all contexts, for whole words, and without an expiration date. Fine grained control over the filters created are planned. Furthermore, for whole words, word edges are weird. For a whole word phrase to match, the beginnin and end of the phrase have to be one of a-z, A-Z, or 0-9, and the character before the phrase and after the phrase must not be one of these. Matching does not care about canonical Unicode form, I'm afraid. Example: > **<kensanata>** filter create twitter.com > **<root>** Filter created > **<kensanata>** filter > **<root>** 1. twitter.com (properties: everywhere, server side, whole word) Differences from the Mastodon Web UI: case is significant; toots are filtered even if you look at an account timeline or run a search. ## notifications Use **notifications** to show the most recent notifications again. Use **more** to show more notifications. Note that there are settigns to hide notifications of a particular kind. Once you do that, the **notifications** and **more** commands may show less output, or none at all, as the display of some notifications is suppressed. See **help set hide_boosts**, **help set hide_favourites**, **help set hide_follows**, and **help set hide_mentions**. ## api You can send stuff to the Mastodon API yourself, too. Use **api** to do this. Example: > **<kensanata>** 23:19 <kensanata> api post /lists?title=test > **<root>** id: 635 > **<root>** title: test bitlbee-mastodon-1.4.4/doc/Makefile.am000066400000000000000000000027721363442034300175630ustar00rootroot00000000000000# Copyright 2017 Artem Savkov # Copyright 2017-2018 Alex Schroeder # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . EXTRA_DIST = mastodon-help.txt all-local: HELP.md install-data-local: if [ -f $(datadir)/help.txt ]; then \ for file in $(EXTRA_DIST); do \ mkdir -p $(DESTDIR)$(datadir); \ $(INSTALL) -m 644 $(srcdir)/$$file $(DESTDIR)$(datadir)/; \ done \ else \ echo "Detected dir $(datadir) is not bitlbee's data dir"; \ echo "Skipping help install"; \ fi HELP.md: mastodon-help.txt Makefile @ac_cv_path_SED@ \ -e '1i# Bitlbee Mastodon\nThis document was generated from the help text for the plugin.\n' \ -e '1d' \ -e 's/^%$$//g' \ -e 's/^\?mastodon /## /g' \ -e 's/^\?/## /g' \ -e 's/\*/\\*/g' \ -e 's//**/g' \ -e 's/^ \*/* */g' \ -e 's/\*help mastodon \([a-z]*2*\)\*/[\1](#\1)/g' \ -e 's//\>/g' \ -e 's/^\(\*[^ ].*\)/> \1 /g' \ < $< > $@ bitlbee-mastodon-1.4.4/doc/mastodon-help.txt000066400000000000000000000614261363442034300210430ustar00rootroot00000000000000?mastodon Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. help mastodon news - Backwards incompatible changes help mastodon register - Registering an account help mastodon connect - Connecting to an instance help mastodon read - Reading your timeline help mastodon post - Posting a new status help mastodon undo - Undo and redo help mastodon dm - Sending direct messages help mastodon context - Showing a status in its context help mastodon reply - Replying to a status help mastodon delete - Deleting a status help mastodon favourite - Favouring a status help mastodon follow - Following an account help mastodon block - Blocking an account help mastodon mute - Muting an account help mastodon boost - Boosting a status help mastodon more - Getting more information about things help mastodon search - Searching for accounts and hashtags help mastodon spam - Reporting a status help mastodon control - Commands in the control channel help mastodon hashtag - Showing and following a hashtag help mastodon public - Showing and following the local or federated timeline help mastodon lists - Managing lists help mastodon filters - Managing filters help mastodon notifications - Showing your notifications help mastodon set - Settings affecting Mastodon accounts % ?mastodon news Use plugins in the control channel (&bitlbee) to learn which version of the plugin you have. Incompatible change in 1.4.3: The URL path is ignored in the base URL (such that we can use /api/v2/search). Incompatible change in 1.4.0: If you have subscribed to a hashtag, you need to change your channel settings and prepend the hash. Do this from the control channel (&bitlbee). Let's assume you have a channel called #hashtag. It's room setting should be #hashtag. If it's lacking the initial hash:  channel #hashtag set room  room = `hashtag'  channel #hashtag set room #hashtag  room = `#hashtag' Don't forget to save your settings. % ?mastodon register You need to register your Mastodon account on an instance. See https://instances.social/ if you need help picking an instance. It's a bit like picking a mail server and signing up. Sadly, there is currently no way to do this from IRC. Your need to use a web browser to do it. Once you have the account, see help account add mastodon for setting up your account. % ?mastodon set These settings will affect Mastodon accounts: set auto_reply_timeout - replies to most recent messages in the last 3h set base_url - URL for your Mastodon instance's API set commands - extra commands available in Mastodon channels set message_length - limit messages to 500 characters set mode - create a separate channel for contacts/messages set show_ids - display the "id" in front of every message set target_url_length - an URL counts as 23 characters set name - the name for your account channel set hide_sensitive - hide content marked as sensitive set sensitive_flag - text to flag sensitive content with set visibility - default post privacy set hide_boosts - hide notifications of people boosting set hide_favourites - hide notifications of favourites set hide_follows - hide notifications of follows set hide_mentions - hide notifications of mentions Use help to learn more about these options. % ?set name Type: string Scope: account Default: empty Without a name set, Mastodon accounts will use host URL and acccount name to create a channel name. This results in a long channel name and if you prefer a shorter channel name, use this setting (when the account is offline) to change it.  account mastodon off  account mastodon set name masto  account mastodon on  save % ?set hide_sensitive Type: boolean Scope: account Default: false Possible Values: true, false, rot13, advanced_rot13 By default, sensitive content (content behind a content warning) is simply shown. The content warning is printed, and then the sensitive content is printed.  [27] [CW: this is the warning] *NSFW* this is the text If you set this variable in the control channel (&bitlbee), sensitive content is not printed. Instead, you'll see "[hidden: ]". If you still want to read it, visit the URL.  account mastodon set hide_sensitive true  save Don't forget to save your settings. The result:  [27] [CW: this is the warning] *NSFW* [hidden: https://social.nasqueron.org/@kensanata/100133795756949791] Additionally, when using rot13:  [27] [CW: this is the warning] *NSFW* guvf vf gur grkg And when using advanced_rot13:  [27] [CW: this is the warning] *NSFW*  CW1 guvf vf gur grkg All sensitive content is also marked as Not Safe For Work (NSFW) and flagged as such. You can change the text using an option, see help set sensitive_flag). % ?set sensitive_flag Type: string Scope: account Default: "*NSFW* " This is the text to flag sensitive content with. You can change this setting in the control channel (&bitlbee). The default is Not Safe For Work (NSFW). If you wanted to simply use red for the sensitive content, you could use "^C5", for example. Be sure to use an actual Control-C, here. This might be challenging to enter, depending on your IRC client. Sadly, that's how it goes. For more information, see https://www.mirc.com/colors.html. % ?set visibility Type: string Scope: account Default: "public" This is the default visibility of your toots. There are three valid options: "public" (everybody can see your toots), "unlisted" (everybody can see your toots but they are not found on the public timelines), and "private" (only followers can see them). For more information see https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md#toot-privacy. This is used whenever you make a new post. You can override this visibility for new posts in the control channel (&bitlbee). See help mastodon post for more.  account mastodon set visibility private  save Don't forget to save your settings. % ?set hide_boosts Type: boolean Scope: account Default: false Possible Values: true, false By default, you are shown a notification when somebody boosts a status of yours. This setting allows you to turn it off in the control channel (&bitlbee).  account mastodon set hide_boosts true  save Don't forget to save your settings. % ?set hide_favourites Type: boolean Scope: account Default: false Possible Values: true, false By default, you are shown a notification when somebody favourites a status of yours. This setting allows you to turn it off in the control channel (&bitlbee).  account mastodon set hide_favourites true  save Don't forget to save your settings. % ?set hide_follows Type: boolean Scope: account Default: false Possible Values: true, false By default, you are shown a notification when somebody follows you. This setting allows you to turn it off in the control channel (&bitlbee).  account mastodon set hide_follows true  save Don't forget to save your settings. % ?set hide_mentions Type: boolean Scope: account Default: false Possible Values: true, false By default, you are shown a notification when somebody mentions you. This setting allows you to turn it off in the control channel (&bitlbee).  account mastodon set hide_mentions true  save Don't forget to save your settings. % ?account add mastodon Syntax: account add mastodon By default all the Mastodon accounts you are following will appear in a new channel named after your Mastodon instance. You can change this behaviour using the mode setting (see help set mode). To send toots yourself, just write in the groupchat channel. Since Mastodon requires OAuth authentication, you should not enter your Mastodon password into BitlBee. The first time you log in, BitlBee will start OAuth authentication. (See help set oauth.) In order to connect to the correct instances, you must most probably change the base_url setting. See help mastodon connect for an example. % ?mastodon connect In this section, we'll sign in as @kensanata@mastodon.weaponvsac.space. This section assumes an existing account on an instance! Replace username and Mastodon server when trying it. In your &bitlbee channel, add a new account, change it's base_url to point at your instance, and switch it on:  account add mastodon @kensanata  Account successfully added with tag mastodon  account mastodon set base_url https://mastodon.weaponvsac.space  base_url = `https://mastodon.weaponvsac.space  account mastodon on  mastodon - Logging in: Login  mastodon - Logging in: Parsing application registration response  mastodon - Logging in: Starting OAuth authentication At this point, you'll get contacted by the user mastodon_oauth with a big URL that you need to visit using a browser. See help mastodon connect2 for the OAuth authentication. % ?mastodon connect2 Visit the URL the mastodon_oauth user gave you and authenticate the client. You'll get back another very long string. Copy and paste this string:  Open this URL in your browser to authenticate: https://.......  Respond to this message with the returned authorization token.  **************************************************************** Once you do that, your login should complete in the &bitlbee channel:  mastodon2 - Logging in: Requesting OAuth access token  mastodon2 - Logging in: Connecting  mastodon2 - Logging in: Verifying credentials  mastodon2 - Logging in: Getting home timeline  mastodon2 - Logging in: Logged in You should now have a channel called #mastodon.weaponsvsac.space@localhost where all the status updates and notifications get shown. We'll call this your account channel. See help set name to change it's name. Mastodon gives BitlBee a permanent authentication token, which is saved in your configuration. You should probably save this configuration.  save  Configuration saved % ?mastodon read By default, you are connected to a channel showing your "home" timeline. You can use use the command timeline home to redisplay it, followed by more to go futher back in time. See help mastodon public for the other timelines. The default mode setting is chat. This means that each Mastodon account you add results in a new channel in your IRC client. Use help set mode in your Bitlbee control channel (&bitlbee) to read up on different modes. % ?mastodon post The default commands setting is true. This means that anything you type is a toot unless it looks like command, in which case it is handled as such. In addition to that, you can use the post  command. If you set the commands setting to strict, using the post command is mandatory. Use help set commands in your Bitlbee control channel (&bitlbee) to read up on the various commands. New posts get the default visibility: "public". See help set visibility for more. You can change the visibility of the next toot by using the visibility command.  visibility unlisted  Next post is unlisted  Hello!  You, unlisted: [06] Hello! Use cw  to set a content warning for your next reply or post.  cw capitalism  Next post will get content warning 'capitalism'  No! Just no!  You: [09] [CW: capitalism] *NSFW* No! Just no! When mentioning people in your toots, make sure to qualify them appropriately.  I'm using @kensanata@octodon.social's Mastodon plugin for Bitlbee. By default the Mastodon server limits your toots to 500 characters. Bitlbee tries to compute the message length based on the various Mastodon rules and prevents you from posting longer messages. Use help set message_length in your Bitlbee control channel (&bitlbee) to read up on the hairy details. Basically, some aspects of of your message will count for less: URLs, domain names for mentioned user accounts and the like. See help set target_url_length for more information on how URLs are counted. Note also that Bitlbee itself does word-wrapping to limit messages to 425 characters. That is why longer messages may look like extra newlines have been introduced but if you check the status on the web, you'll see that everything is OK. % ?mastodon undo Use undo and redo to undo and redo recent commands. Bitlbee will remember your last 10 Mastodon commands and allows you to undo and redo them. Use history to see the list of commands you can undo. There is a pointer (>) showing the current position. Use history undo if you are interested in seeing the commands that will be used to undo what you just did. % ?mastodon favourite Use fav  to favour a status or the last status by a nick. Synonyms: favourite, favorite, like. Use unfav  to unfavour a status or the last status by a nick. Synonyms: unfavourite, unfavorite, unlike, dislike. % ?mastodon dm There are two ways to send a direct message to someone (also known as a DM). If you are following them, then there exists a handle for them in your IRC client and you can simply /msg  To DM anyone regardless of whether you follow them, use visibility direct and then write a post that mentions their long @nick@server account name. See help set visibility for more. % ?mastodon context Use context  to show some context for a status or the last status by a nick. This will display the ancestors and descendants of a status. Use timeline  to show the most recent messages by a nick. Use more to show more statuses from the same command. Use timeline @ to show the most recent messages by an account that isn't a nick in your channel. Use more to show more statuses from the same command. % ?mastodon reply If you use the default IRC conventions of starting a message with a nickname and a colon (:) or a comma (,), then your message will be treated as a reply to that nick's last message. As is custom, the recipient and all the people they mentioned in their toot get mentioned in your reply. This only works if that nick's last message was sent within the last 3h. For more information about this time window use help set auto_reply_timeout in your Bitlbee control channel (&bitlbee). You can also reply to an earlier message by referring to its id using the reply  command. Again, the recipient and all the people they mentioned in their toot get mentioned in your reply. If you set the commands setting to strict, using the reply command is mandatory. When replying to a post, your reply uses the same visibility as the original toot unless your default visibility is more restricted: direct > private > unlisted > public. See help set visibility for more. You can set a different visibility using the visibility command. See help mastodon post for more. When replying to a post, your reply uses the same content warning unless you have set up a different one via cw. See help mastodon post for more. % ?mastodon delete Use del  to delete a status or your last status. Synonym: delete. % ?mastodon favourite Use fav  to favour a status or the last status by a nick. Synonyms: favourite, favorite, like. Use unfav  to unfavour a status or the last status by a nick. Synonyms: unfavourite, unfavorite, unlike, dislike. % ?mastodon follow Use follow  to follow somebody. This determines the nicks in your channel. Verify the list using /names. Usually you'll be providing a local or remote account to follow. In the background, Bitlbee will run a search for the account you provided and follow the first match. Sometimes there will be nicks in the channel which you are not following, e.g. a nick is automatically added to the channel when a status of theirs mentioning you is shown. Use unfollow  to unfollow a nick. Synonyms: allow. % ?mastodon block Use block  to block a nick on the server. This is independent of your IRC client's /ignore command, if available. Use unblock  to unblock a nick. % ?mastodon mute Use mute user  to mute a nick on the server. Use unmute user  to unmute a nick. Use mute  to mute the conversation based on a status or the last status by a nick. Muting a status will prevent replies to it, favourites and replies of it from appearing. Use unmute  to unmute the conversation based on a status or the last status by a nick. % ?mastodon boost Use boost  to boost a status or the last status by a nick. Use unboost  to unboost a status or the last status by a nick. % ?mastodon more Use url  to get the URL to a status or the last status by a nick. Use whois  to show handle and full name by a nick, or of all the nicks mentioned in a status. Use bio  to show the bio of a nick. Use pinned  to show the pinned statuses of a nick. Use pin  to pin a status to your profile and use unpin  to unpin a status from your profile. Use info instance to get debug information about your instance. Use info user  to get debug information about an account. Use info relation  to get debug information about the relation to an account. Use info  to get debug information about a status or the last status by a nick. Use api  to call the API directly.  api get /lists  {  id: 5  title: Bots  }  {  id: 9  title: OSR  }  api delete /lists/5  api get /lists  {  id: 9  title: OSR  }  api post /lists/9/accounts account_ids[] 13250 % ?mastodon search Mastodon allows you to search for accounts, hashtags, and statuses you've written, boosted, favourited or were mentioned in, if your instance has this feature enabled. Use search  to search for all these things. You can also search for a specific status by searching the URL of said status. This sounds strange but it will allow you to boost it, for example. If you want to show the statuses for a specific account or a hashtag, use timeline . If you want to subscribe to a particular hashtag, see help mastodon hashtag for more. % ?mastodon spam Use report  to report a status or the last status by a nick. Synonyms:spam. Note that the comment is mandatory. Explain why the status is being reported. The administrator of your instance will see this report and decide what to do about it, if anything. % ?mastodon control As we said at the beginning, the default mode setting is chat. This means that each Mastodon account you add will result in a new channel in your IRC client. All the commands mentioned above are what you type in this "instance channel." There are some standard root commands that only work in the control channel, &bitlbee. % ?mastodon hashtag Use timeline # to show the most recent messages for a hashtag. Use more to show more statuses from the same command. If you want to follow a hashtag, you need to use the control channel, &bitlbee. Here's how to subscribe to #hashtag for the account mastodon:  chat add mastodon #hashtag  channel #hashtag set auto_join true  /join #hashtag Don't forget to save your config. Use channel list to see all the channels you have available. See help chat add for more information. Note that where as you can still issue commands in these hashtag channels, the output is going to appear in the original account channel. If you try to subsribe to the same hashtag on a different instance, you'll run into a problem. The solution is to use a different name for the channel and then use channel set room #hashtag.  chat add mastodon2 #hashtag  A channel named `#hashtag already exists...  chat add mastodon2 #hashtag2  channel #hashtag2 set room #hashtag  channel #hashtag2 set auto_join true  /join #hashtag2 % ?mastodon public Use timeline local to show the most recent messages for the local timeline (these are statuses from accounts on your instance). Use timeline federated to show the most recent messages for the federated timeline (these are statuses from the local accounts and anybody they are following). Use more to show more statuses from the same command. If you want to follow a hashtag, or the local, or the feredated timeline, you need to use the control channel, &bitlbee. The following assumes that your account is called mastodon. The chat add command takes the parameters account, timeline, and channel name. In the example we're giving the channel a similar name. You can name the channel whatever you want. The important part is that the channel topic must be the name of the timeline it is subscribing to.  chat add mastodon local #local  channel #local set auto_join true  /join #local Or:  chat add mastodon federated #federated  channel #federated set auto_join true  /join #federated Don't forget to save your config. Note that where as you can still issue commands in these channels, the output is going to appear in the original account channel. % ?mastodon lists You can make lists of the people you follow. To read the latest messages of people in a list, use timeline . If you then create a separate channel for a list, you'll see only statuses of people in that list. These are the commands available: list (to see your lists) list create <title> list delete <title> list <title> (to see the accounts in a list) list add <nick> to <title> list remove <nick> from <title> list reload (when you are done changing list memberships) timeline <title> (to read statuses from these accounts) Example: <somebody> follow kensanata@octodon.social *** kensanata JOIN <root> You are now following kensanata. <somebody> list create Important people <root> Command processed successfully <somebody> list add kensanata to Important people <root> Command processed successfully If you want to follow a list, you need to use the control channel, &bitlbee. The following assumes that your account is called mastodon. The chat add command takes the parameters account, title, and channel name. As list titles may contain spaces, you need to use quotes. You can name the channel whatever you want. The important part is that the channel topic must be the title of a list. <kensanata> chat add mastodon "Important people" #important <kensanata> channel #important set auto_join true <kensanata> /join #important Don't forget to save your config. % ?mastodon filters You can create filters which remove toots from the output. These are the commands available: filter (to load and see your filters) filter create <phrase> filter delete <n> Currently filters are created for all contexts, for whole words, and without an expiration date. Fine grained control over the filters created are planned. Furthermore, for whole words, word edges are weird. For a whole word phrase to match, the beginnin and end of the phrase have to be one of a-z, A-Z, or 0-9, and the character before the phrase and after the phrase must not be one of these. Matching does not care about canonical Unicode form, I'm afraid. Example: <kensanata> filter create twitter.com <root> Filter created <kensanata> filter <root> 1. twitter.com (properties: everywhere, server side, whole word) Differences from the Mastodon Web UI: case is significant; toots are filtered even if you look at an account timeline or run a search. % ?mastodon notifications Use notifications to show the most recent notifications again. Use more to show more notifications. Note that there are settigns to hide notifications of a particular kind. Once you do that, the notifications and more commands may show less output, or none at all, as the display of some notifications is suppressed. See help set hide_boosts, help set hide_favourites, help set hide_follows, and help set hide_mentions. % ?mastodon api You can send stuff to the Mastodon API yourself, too. Use api to do this. Example: <kensanata> 23:19 <kensanata> api post /lists?title=test <root> id: 635 <root> title: test % ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/pics/������������������������������������������������������������������������0000775�0000000�0000000�00000000000�13634420343�0015710�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/pics/screenshot.png����������������������������������������������������������0000664�0000000�0000000�00000354611�13634420343�0020605�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������PNG  ��� IHDR��f��v���OR���sBIT|d���tEXtSoftware�gnome-screenshot>�� �IDATxwt&!J$@(HH)*RP&Ei{/!^B!ݝy&4Rv!9Cw;[$ AAAA᭓3;    U"0#   ID`FAAA!    B&AAAAL"3    DfAAAA2.x8݋&R]D t00<m"OHӚG&vic29g\%Jb ;-hնٕQag:5@q<ؿGXh%ǟ$%ɟdeѼz-,^sϵΓ~Ar%cư^jKzezc{~zj|: %h%VbLN-o%Am6='}AAȂL Yq/,45 8yJf>&GQiuAG>s"r =f^ Tȹ='yXz5tiY>j8K/\Cg<=OGn祒 7:"|V)6#shCSդ/]bx49 e#bμ?%&,+׋Y_c:'�(+2   B HKaY &`._<샄[ݞ|Q&Sʾ`sOv47Ё8|.gKV)0EIIąX\+g'i(;' \H8P|d�)*0>" hFdXa6   Y]3#l\k$ދ]{ұvIs f͈DQ2Hw[xe{wqR}xFɞ_Y%V jܩ٤"O7bpoR9p gOÒ|.NM/;Y7c&kD&/iThߓn Ń{X<c)@؏_s`u34d<*wWFvo!@*6cdQ&Bb;o >ed0 47Vv37pAG70^[H/WpW:({nxnYLTݎ+n~֏A+ô=_)J9AzM{xf   YS38=ihMix$'>v\ើ( eX'|13$GMQk,:sΆS}*uO0rav/F8.P&]a{x}НrH�œ0s0>qc?}OrK(/|[8SGtߎ1䋹\2�J^ʗ>}ӟ:P` N`)tjo_=TB=z Ѯ?wĥ4p#'~3<Ni)C0a]&04A97z˺aNF#y4} T0* elAx e=:˩3rNx]{x)B3   oJs`FWc#g̐OS}[-yԧ] p75):,gm4t#iM^:>B*[e_xʎQmO }vrEr7^BK@lH�#6,d4?Q8ޭ>K59ɹ*mlv>o`LM9i[ec� ܳb8h tlSyΉ<yeWyjCWi�˶Y3>q;%vuP0rn_7 ^TpwPoLXNW#׵z   dqitIJ)+J N)Ч2G]p]7,'9C:+ٝ4^ƛ\M(Q[/9N_'7ʁ$h!D)ukx!1G{&vz-ȱ,3w>䫯G2$/nxd"J[0D'<&o6ߩ8|l;])vlqNHnnAAA6s̤M M2齶,(Jo8Т/c<HJ.%()W@#o2_T4>* Cgf`0M)=,~kŚsl]6Gat6^A@ց̫PQ*q $)%!:Rx 02Nc?q`y$   05nPrN3wO1%5dɆ9q`Er6DD11ɉ2Pvv\M#13J~s^\.b/~#8GvN_-y&D& &GsXCc/24d3obe<Ro   kX4Q9Kګcrc<derQm5Z!ڋ'Ѡo_٭�U7d*OR rcooAW>#/KxL/:=M(iݪՓJ{2~6 AKCཬ+i\gB'u_mbH.נ=ۻQ9d{dz^+^eߥS'm? 0h*mOɑW-'u;2LޟJ<;ڃ   73I} ig A_p~PރO\?S9{v'7tCM1c33޴9L8s?TIi'q.KcƼ7/' '@]6؟;u}~4M~`u>'c3G1m_{Z~!aZZf cǽ0b}g̈q@a91'jg^;j;˶_ 8 }Y/hƈݻ{_pö9)2'~5*Gv_$0.)EfcZ!=a[Ș'H|׷AAAMxɊd/MJ-UD{AA̜ WAAA?MfAAAA2    d1nj    B&=fAAAA2    d`<    'I)AAAAb(    B&evAlKΔ鿇~NO{6-,EQS;C^߁QcKAA=fAAAA21# ,7{<ݧ$5~)wqAA7Ho }-~>fN Ø�jC|cJ<i]xmLo*m5Gr?*aSF{srWdTAA?MfAΑ\>(x )| +tB"؝ο$,m4VGuY7|CAAl.}sDe߼eJ♯8~-hv sZ#F?OrL7 |so3xݵ2H))˂IQ3;O2fL(REJ13;O`?Ҥ6D.k8>i"xzůJz]˥O73|)4w}/\�MNP a렱Q,,_"YXHhYQXP$&dërD!$d Yn3l~_=G4^Q.,}5[ 6i~izo;c޹Oed"~ndh5Z#1~Kω2ffWa>K?v㏲muVIWM&aG5Mp¿(w+ԗZ:[hAAJ[c {cn�%5s{K^aX52+]ASs:v0A ( (O#4ιʴ{5Kꛃ2d۞zu4ĩRAAl.MНS|(Y&g叙f\Uȥka'2q;/4lk +F8̿YQ_,]̌9pGzn0kG~h+v3]${JtΦMQ6:'xxgڈ|gܼ~ľ�<cpjHO"rAA? C 4.p!jńch([yCy#L<۵-hhְan^)\8 vϾ2jj.xfӞٙ<\=z~_%Ze]~OlsNs]5W=vї/ߣD4 r>dt'tؿ14xS  QTs맷jhՇ73T+-&l52+7D ª1VVp:؈|FοC!!9Z>ƍs5[ӻ~ }1GgvR!`kIغq z'ӓ$5&#q~l8ebDmUjAAAA2St(PzP/*vb!�TTVZ^pl0vюo}KB)=4(L̓Y~:.$=*Uy'p&#wG5aŢG7x:㣹|6?1szܻty Y>%?b~)+7>%޴{l[!:wX)O<]J}<>AFҒH?ӆ.D/Me&_͆ڑ"ffU=a�%>mD<*H'а̵ؽ6Ji:7\&_ʒ'729-O>բDΔ,瞨|l-dqg }1x_LЮ6MAiW d/o; ЌZ:6kӨoIIFG.4n>^DamQ Mn\2ys,3)kN( ڌ\gU&+Pl>dxĕ &_Ԉ�2d|)%46-s4slq3Th TGdRh *חsD%FKP$\JoP} S Vč*75 }n 2)3yHFD}di,SW;ڎ.d$]2=_7o+'F÷ m|NqI)ac=wr-ubt;J%g:4v~ΖC?P9j%i߀i-k %Vn{ػyQ|5ǭK3mrʫ(uan4/݆k㜁a:y g\D)9=SR}})+Jtcl ы0#7&؟>[qձ]#z6c+kiͭ)txVtQJ>]r:Ȏ(�;L?˒YX Neu=GlM8}Ps`Cr(oIUvls&yRzgJ=y6"gz\=\qԽ12fnIN=;*-rýq(9]|9O>IZiBNsO?{tۥ \b#gТ}Q ٍna2~N|wо_ٔovG\GM)\}VSY4i?c^l" E9cb&90J}l@Fy;,o"yun]8`*O9HI:јd8jX,Tҡc2-e/F'Y&,;IAC I}سx6Ɛng~O1pvr2Q FBO{67W3nvKsٞd唓�XS,j  (x&ʄZ:y[m{Lxv')i&[`rqh4OѤ|L$/D#h=&Qw ?}Ⱦ^F%4o<h"pz}Sd$6I72+8j[i7218/c$<k @ly^:e0$Y`Z^2n,H/08ml_΂eן#aS<V7NI,]JK}l uCet>2}OH>a9]CpAJi4Nq8g6O h(ߌ#Nf7- sdqtُ?y%GS?g !Vǫ2kEs} :%`Ӿ 8F䉽z3ӕgufWr.YSp JrⷅGG3%uz3q[lPFB2]ǭ2S ġ  pǁ?̶_r賔Ց8x"",1LgDmir"Hi[\nG/4FBr܄"pzi\n+m"tnZlONI;b)~$c2wE=",<*O_J\OthYoY|ݬy!$O*b&L}lDEBUwdr~G/(T$'4n 3psƝќ<rR],qTx:ȅˉ>aI2 ?a{D`G$R$bx4)WV[fkA)䤡L*=aW=wYkIGih~:.=BgOJ*Ϟ3%;gtcJ�f|,?cy{1L޺m⛮?7AP&!-ϲ m μJQ9D%cMfYY'3eڂƝ?19 4Qӈa3οAMX, (x%hm%-e;<k`0%':[6ۂk6\qFz-C ȜݗYU<BmR*wl*u'czć&)tmyCv4n:ľY0XtP-`ܶB5m!O[>95y-/S2f2/b 08+T*cۄ+mF?u͟42G\$`6]X{ :}U1m-nDWݎM5q5(#¿h,Z|ՔtMO`klf{{"yzxvN͎ߛG'I8%{9a,s߮knc o1bş'xhQe|>y*4n3pg'cy+Pe-7Q{L(Aa)EįvXRSɢ-\_&DPE"O= Vj=f)^!fX*,C >d[O?H:xwVp}mlҸ5_YI7@;쵧N=y_ӁI1l5vnψzjQY8 <~^|d\+teٷgs4ܭGdo+gq,T #q1(o=)nڸ Yid1|R  [A|ܳ Dp={w-gj+WFeƄ 6zNW7LX>cNhlb֥֊qGquCu 8DtRt~ 8lڛ"tiv;`_ٽc!c>(ޔrɚx;Nfu7O]ġJfĺ]($!rR͗ mlAx2>C1fɎb=~+4,muhތ"Sq9n [R9A�� �IDATMlD4C~9obhy[:3l�7oF$<wȈ7K-n1*5>ޯxs7cWum@9#(aT.U7c̸iNq�A;"Yݾ,g;EbUe)}ҙWQ50;gqI. &بio�0ߑ×c<7U#RM8ƌqT6ti_f?Ј:;ZxB9퍝@ N޸ "_(>Gs^`\{b®t T4!ɍ'IZSFmPost0];+P4kГL7dl U3T�"4Rxc~ЋBgQo*σ_܌J <s$3j<gsoD zʖMM%7*9gi;MQl=Ɠ&<W(LOzy7z0Ř?S 1q2q|0HʧPqBث`1 9+H%:foʣ*_TUJ2*~ygdolMD7(ag6epy06_km<Ѹ93aRB0riVn땜)J;s+S2Ʀ4x yl;{w)_hȑh#Žp-[> fT< 5ߏLۅR:]a\JrwK|]([<!u!&4BnaMV$t_.i'B-MWP2yQqS?|LʿS"ah2F4Μc볽{~ G8[_Qp![ g{A#e{af9{y1OF'|iGIdfV[H%rIP,UFV4.b˹AȪ2n4h' .&tk>k>&UCܰ2*TU_Y{[Lt%˷29*`ܻy5#|a֢.0{Fm=+Pf-Q> A7Jzr< vjP+'Ep܍+eRց<R4OtJ]P~NdQArPeJ]fҵXA;l)["v L’@Q늬 Cq3%TZv&_vT7>oÉx|w"9{_xBJ+p:ך2񎽃Ѣyx~n=9UtH9(R ̝Y<I?Uh{1+~j_\d' l=24^L!]G-RnzW#Ԣ:bߪ7{4b{kHO]$r7V(;BO i֐-5J#:`̳MJגT6M,΋~hZ$<cȭ\p[PBߔF e;tfҍr*d.x-"v }?\DZL ^z[b<4W?jVKXb+Z[^$gmE^|-QH8p/O5a0�wJ1ؗzBӢ _<3b2!gr=$qP-%/#2y0[AeT(#}Z(BPȚYS'Ym!ٽYGŽQy' gԨ8Ưۑ7z|?2k; 6ԦқtkW{[Vb?Yz 'gSL K1PD[Z&0$H`473.ʐPͼQC $iwǴߥy-'"HȞȞ('y6k.sn/� =_.$pMr޼8%ʳ'f?T@{iG۽5= ’\ٹ1&l 7һVR~ ñ} oAQ'OwyTjiXy+;fOSs)c2vęE?KNJGU?v!O aI)-9KBFю6 ̟qjgð|-2k (χ %[*Jbw<aJwQ7yUYyɓ\? 0ZTh|Agqm)?/CWM|r%#wq --ebRwh\5+52c{C8d<)[V]?JfY+ wg~<x E̕3`4a;̟q ~z⣒{qoJOݰͻ9|5t^!qIy%f*; f֔UȊ:iLk Qzm ֤봨:Z8)i:f!(cŶP#>2|;9=*ݲ6+\a蹎WcsТoנn)lڗdΥecI"x(InteE(JSQJ 7<˖ߗp753qڳ2HZWF:4m7B 9`ε7zZ+7H*c0' ;uΛo$ōrGzT{C]O Q93AM:1gmu_+'B`k$WpJ a/8;(M_M&nՔ̳̀J7eD3βLuje ]}#x͛秐pڌiH͓;1fO$ aBwik%7M7̭bsXQK{9 D]ɂEKYx 3WW.J|I钕wE8q{/?M!(U$q%5k];xVFofHdwΑ df`~ۛߎU4ՓZZxךmau2sxW7CAltq](VQSͼT%WL5i5t 'T7JM`<LqV߬I.Մb9xP_pk vqzl`{5cwnOԠq9e^Bفu{3}!vћ:_(Q;ͽ8:szZSL76 tCK>phwLoi/̤+ӒEb잱g ¯leTyYg(=w$Oaw{t<Ԣz(J2l(3(}(7L1WpǤ(`rKᱽ${i^E1)@ F$IGǼw</3�25gxo*]dJԩ-9^iԓG˼܋Тr`74Y6}M(kp[o%d9JpDݔK KiY:HCUYgs7bUViʋc6}1x_ʫOe H)NfvE~]9˕-m~sBRp-ي#гO?*Lz*TxĶQ8Ŗ(HbS(%勑vym] )T(9;W%4r/:&~1n p$pmRB*_6n6L7ߌ\_=K .:] Z.1bmp;•:ʴh; Y#u`Fэ]'H 0,^OQR&d9Lr 2B)KA{ -:aԗ]`s">6mI vy3*Q(wG*+mZֱ73|/ -1iK3x=|mЎ2T'm Cךc%XNfm4&Y:2AH*mZ!+CʿKFLAf 9[Ѡa;>3/_sU*]O/b|:x8"or/\ӪeSӘ=amp%@C º{a챤7#DXL`<8 AO35ds*jhA "ſznD9 {M6Gty*5M{#knm񌓝lGIyU[by+h/y|NB_+KzM4^W6)#MWz,/ );1RPlPFޫl؞? 2H2˛WrL.`DiY5G5$ _!2PC_¥D6zM%ڎz}⻬*cGj,&,xK)�_͟l]6FKÌ_,vy_k.0=A)TE-8yhBNOGVRܣtFy>vh7c3q*C?kP1ޅ|%ji؟5T|ƫg9{; U;V Jt]0>G1ћ|BP>).Gu)MqBWEm^r͸,v_dzO-xEYr"G0ЗN1^?mHq}'(#'8|mҎe'-u&2~j 9'U>Q{('gj oHhkǽp�ӱb[KSe,:v/9SC  Y7{[Z$>bWFv-K69 5dUϋ[}O<^;ug vN-_ȿ{Wl? 8Q'aFBg.YƯQg6&X('OlֆsKXS<ε;yB�WR"EjpcgYMxl2|Q/p%=y!G{|mܽzIBv.NpJv.)nc^Ǥqs)ɊjnWM\=<U56`åFb"+n?h`2Z"B/9I_;ے0c5[H1J9ˍ: գ8߆U,*ϵ5u YŮޚeO+80dX Z2_C՗<{ԼR7O"nOs&}.:Xګ[[0GnNt?5a`v;VS`9¿kv)UF'n]!Jsm tJQLq\=4^)>Q^&=GIMټϔ.3SAǏhnGg62glF:2hOWz$9]#Nofeoy$=4bXSַIHX)-CJGظ+8XE_a) BRiΞlEMvB)g\e'{PfxF.nhJ iK;[YG:53*wL>^y6 =<g -ZRL#WΌ=Mi>QBvM-N~He_RH7jW 2j`ٯ@?sJAm-!& /rev wkE^jXف13|(Ґ(H?7*&s1(ɗ#+jPY<G@aHC7w; Jv%v$a<4m^a^] xڪkVC%Ƨ+ELV ?t?Xg$oWeFtĥ:֌kciFՁ(CuS&Ό3fUn-SJ,BW5P@x ʷnʹ R>ض/Kd/!'42rp:'r/#ll$KŤze*7c-&:vS**:hċ7h`^6+:Tp!8`9{& α~&jG_ҪuiƝ93/G^2pa'DjDˡvMiPz MvJSRoP\Ǥ_~w_d&*׌EcG_"A|_5~dr:Mm+Uݚ<}&Y8^~2u&?}CQx@*Ng5.Jl=IGEAz( f1jql*P<=ekܵ<WA8N_`ًzB8:gNFgkךvdMt_B6T_ϾH ԧ<モ:Ig +A4\TDxOYݯ>S}]\D'7m-i�D5qa]j~73`k0**/N-d l&cZ^IIN6)~@彏(:TP_pwZ_OQ4g~R֭|ih $ݒ#b4L7^Ǧ-O0nfoI+HmVL1y^kc}:Qo&M/;խF՛cz8ilݯg2Rpk҄ nGB)�_ UYmIt.뚆`D3٦TX;F$&*P7%O|[{YV VM4f\=eLN%cwPx%�wP]O~H0jܛ`uln6 >3Unƣlj it&]2.Q0/GJNT7;+z%>s\3VBoOrGm`ˇPiZso!\(ߥ,b2rz{F|ae2Wl8aO8gͽt+U\2~ߏy-E [i:{ OUЌ6'Ʀ{d|Fߏ s֍Y|DQs/޵]΍ҡFf0qm;MIīŰ|3ގkͱk_ٿ6fX|c9ו BbixLx<'kdN0D¡b%;tt:]JȮd:g`Xրc-WC"˛t8gZ,Nbs7z"ݒn΋#&SZǽ]r.~@ʯɳ%FOj[uphԟZs% W QIލ(^.JґQ=\`BM\<cǹa~5FdC%PS"Kh*ǝO<!)Iǿ9 g;[Y=Qu$BOOvtTl ~e:odRXPFUQs&*RN2#Ū*9Q (t(R9UH:ʴhO;DfM䘛ܶ�z̏)y${ 3KtCjCa>4Al-X_,%F䦄_N$]QJ͡k2x(l0#Iv_e 蝔=&f Sr?jv٪2xR/&Y$|ҥjy|hGVIIwXjHZxꒄ]e ^-MJDflc7- >;bԏ)Yt%aAʅɕ yUM߱޿umvd@ں29=PpItmX3er?/܅j欢8'w]*Q}:5 E瀃wWElVt)%%e4kW=o4((3e1mjM_wvŻm?ZKʶ3[I~' EȢm@HGT�#:Pu.B$ �8l9oqLt5eJ̱pEvy$r7SkO N6swj;~Ol>+TcO) Ή$N y}%tv-}uQ߶!DiJOd\<@lEd||\ׅ_k9גsݚTPp[>c̲ԜWժ?JҼOvb^-EDJќ ^�� �IDAT-ưurJ'}6W.Aqnɴ]ɭ*Cb/hWۏ|P}= lefB6sWєtE)YT+~~yXG%l g, '{{r)F3 ,/l6UHg'wt';Vf$\k}Ϻ-ز<nٰϞ >e|[ YUnGI@X)0{\=Rg~)TJ|-2I"E;ARdw}<fRCTǛ:4.L{bV-o+ ho9&#~KɁŧ'99C>{9%KtKnܖ캘ݡԻlU ֗ʾK.}⃹1Iz* / $duAY Yp/Nۧãa='ntxxkI^.J8xxS- LL#Bqr#w8V_ 'y/PꬉmL{ l:5kLt{&@rC[_+YD`F4P ݽ)m52$u j12TmCSAF6hgՎ|Sca<ofl}Ѻt.݂Ʃ<غs͚ĆQ >KǬ8 deAD"0#BW;pc S+EZdB· eZ($ zd߃* 7`ຼyAHUW!s8 NØd*T̂s XtbAȸW_A(U2PAQcKf [cUA\t%BpcppK A£Rv|}cfz· ia)q階7X3w{ ه/AxjwTTGo ( "{]5QDS5hL=K{b `GEQ@4eEo~x7w꛹s^ cQ@ @ 2H@ @ bF @  @ @  B(f@ @ 2@ @ DtF9+ N}YQ3Z(5O #Ƒ@ @ ؎IS#77O ?]_%?`ـ.|>/͈ns"6"Jpsͭ0u^Dx8N/_s&^5AZ9>(>nG%,IZ?v#WÕ~X=ߖW^xƳgV<O!,;vNueXx[+AfE|U壦u)E~RCofZojA DwEnU^T cȒ}Rﳈ˪'7v vźMR)E@l,9t@A@!tzv`&'g+]l(@ IOTx注bɑB8d>sIiG! wv.H%Y}81�~X)l֏|#ߨl@Zb&=Vg4r1fH钙|]gp??_쀻ht Ae?{#e{d֙ ;"l$Y}8 o;Օ GYn;kc%Lq=RlV3D KtEGX7:W~r?nNuK'v=>i Ayd2VWSD,wGEq3Y%koG/dbcA}1ZD^AS3JqZ>^Mi?+ Ǐp63-j@t]@ HA? lCgX;!W iFKnp3,K! G "t@ läԠġ_^ ,Eٺ_&HGWVsM6p_5U5K2C*z'g)JWoG_NePWgKZI&*DM65S0G ޟ0x@֫NEpϓL?s+@~ۈ1cĮ N7JP~w~Z~P;DŽ< 0 @n?3D|7qn04J_Ƿ3zeПP ,C.AAڴl6>VZ7<wNJ6zc07U2+_؏ߣj o λ=ưjk"vWTFJcymϑgtlڈ*Kߋbe!o_م46Mhv}^wc5Qi.IW,oW;+4C^x*G-8OVN&-_[Wv)J.MHH�2UO  ",f^m_w5ǣ 6bʫbb>?;xصj>.cnmIk>:cQ}Ggp*$evĆ홿"cS|Mê.WgL5ccZΡ b)3l 8+:}S/5(ܥ2m#8Wmch▉)#3W6zc02+KSHocacV!I}DI:zS!%RlgWN(scvk5W?P-WB3|nOsLn.e斐8Exn4n[33)oW ~3cD NE_ YuM\RXh~~t$REw,M?=]o| wP-P  b562BIaFpH|)OgҊUMHJH}~ߏߎL &X(e3\oax( ƺX!3Sge{YHjUt� `׽~/ 170b֥dN2Y(eBe1h5} _ˤ '>ϗȞ#{#:DOD۷Il8eTسRߟ MRLd:Co't 9F.0 S� mL.9S],FjʛzJDr=$gJ889 ~'!浍W;^tY fȑ5:2UF8YW,@ x#kPz }{V1u@MOk7~ɇM4x8~a۰>qk˫;?6?2~,UVU[87~?'lv+ Yվ̝l^TKq|Njh8y -}Cep΀g"F-b|_:lj'a^=,́>6yD]`֤^AǵWVks6oeTt4nTw*j^lEe1h-{1+Ml>g3v/J5H8R@^s?4Oƛ{xϢSٷR۟ ޲SڃzMd֝>m~Q!] ocXu}o3/*A)4v~f~e `6?6 "9Q,ܰ};V0gD;JGl)lWWG䊿#n"(eR9ksŪ{8y}= s@9425*>>.H@UلnK`�׏qP85ҁcBxtpi]bŽLcYU^=W Tbܪt'{5ַ蓻87=ښ6ʛQيI>Mf2/)D( }CN1.jʅjц u(=6emTh/�l&Z3 z+)[ ~\>%FO*UpkLAP;ۖLԯU (Վ%r-$ƾUmQc~k:.=IiϐFgq[efZ0Llʙ# xp� P4P |`THhJZ\?<y:]=?Nm~lyjխKTY4Va˼a9G~_z;|[>Eݢ_Y_)crQ˸=1N9Ty>%"rM(Ĝ?]_ %o U(ĩXq(Ǎqe|{pTB!Qd^ZjhLY672'O}r\~cޗ27@ Hy s=6qHI4l7c){LlúFrB[-AThՄ"Əj=ڏ,ѾrOCO5_hR< Ϟ,N %ztl_WeiU¬�v~jh_b(9ӸSS7:_% ڼPM7ZQ)cOk1}xҁ>>eE)[|vnpk7[MJng52?îsxmƵ $Vʤo[ ;9'f_&RIV2obͪf~ZW/b8% $\<eUbB J^-lWU. L}0ZyL/v* ȑ)>YNr[PHZ',I72':s՘qb1c #נʝD,e@yIXx.XU^9&' 1;P<ϐ_DiHh_3yrkС*Nܸ3BFt'_ `5*z@ ', A_Ih= 7 _YO5<!/^u@wt:hojL2ZBS  y<Xr;e48%�G^M@.j*a{?(Ԧ#uƞK-� 'wVG^mYiY_Y9oq1^˸?K9+ҦYWo@0V7++ bg•lܶc7#' T4|5eiӲ(ӮG!^DtmN=WĤCYnyA])f+:y .~*OY/cŘ"W,39dx+aJ4k" ~َXU^٪+WlIkGDF /f9l:tGw0ہ?e~xxV'+rhM_TZeQ5+9WI(AEfCjMR;xvAɢ.B[OUw v%jԯaiho6J}V?ZS!ӊL'AlZ9ʺysTs6f~ xh|ϗpﯖJۘEʡu, ٶWDڭiH¥'9V:bna,[B3׭:_>l+oZeNB˘i#|-B1#dzTҟLǰJWVB>4mۗj\O{pvD8v@`5Z*5ot;w�#� P1 oK}}OahhdF!p|v^6 (C_Q;xL;8^^R˙7?B4ҾI]m7L'z) I٢5QAw*W#M% b'aky*sBbTR*U*Q;'; #oC6$]p rI6T,ޙik`Uy-pl37O9ƥْ֎P[:듄Y6)[2k16MH1<*g9̜B "1iyU:_[xc?AEfu5蓎WA[qٔFW晌z#uWcZCf)zUƶ ]+�9&G8n>M@JǗ_SNdN s\5fR~tAwsc?AWFa]ybsi"=;Q0F֚%<qϡޔW(mhҮ})v=ɚǹnq8GZuao9J)(\|kk\zcUDQʛF@)b]u AF$y0OO8)'ι윩D.'^!<4fSWhZU^ yr0^%(Y_qxS,GU^`S~G V&2O>N9|/ VŅDdӢut&_ɚ0ᛳ0*\U~ z@pJݏo_ n"ML2ߘRNs׭> gOGκ4rpP�3nri+dmrt@t1,oUqKSTa˼a9G-":?Q. P8>ɻC3aUyٯ9-*@+%-(\8hP[OJqϡB_HvP{5T3 :<i?mM!brd oV &.G}8]Hb.M GIв@.kPŽ@XD Ȧ^Bx󩌺0ŋi No\޼s=#Uc_"뱮9(W;>* s"- /Š>V}[i3w1׉Jq}ouv#EkC6 9돟%]],SzeY)Wܯtg %CVI)_<OU-xc?sAAfɉ"ƹml�m\4lg @}�aFP[ѺTbjƶ6_jҡe:l7gQĐ.kx̺I˸~]k~up,\߽0rml}*DZD0ʙ�mu)ڗ7j9PB^:m'3llH¹:4Ӱbi\\4N.JR;7M2' zSDEʗ@?l)2?B1:'Mm: GY17-Tj'pB#?7o[z-Fٝ߼R.jկlv8awqNƍ\hѩ*u*,퐯Pڧ5ewY27?8ū9g>l^k-il5R.)kNp)S.R<IR69 =kre.^Խjcc%PuK@%$ٻsqXdQ+:gIO+d Z-2kTMN0~L:\[Ǒ˻h[(Ğ_+oeeT^5뙬5~=gopi,~㏙7XBe(ll7kAO{P!Ί$"fraKNok~#<NQ:q�߰TZTn-˸V=4!5.ď{2-] hI*&2YyyJ`yLdWy$s<܉Qð)s]L H&:*ΜHA=$E)m6b~ϖy13 tk�)_gaC(SN='2927M'X歡xgR%_˫)ߗ׊Cq3'ID/CKZ{!U(o}U}j<7]ď I%!)GДLVP^cNܔnKӲ7 *0g57=AF!Z~6sR ki0m 3AkEfZl s((DL ^92ѱyn6~˪ JG[K${ )�� �IDAT1cHE*p3l 9eQ2J٨ң+U-֯lh#[s%:ӣ_;bb?Ѩ=Q]tWZGm= F/{qlx*α9֎Y5^ﵥ8efVnM")8XgRKWEj?jiJe97-2cȝU&Lɾ@a1TE$% m~a\BRR֌ڃٓpPvJns(@⨪8Q4- U X8Lv&O\}Ur [ʒ(̄OZfa^du[P9|W-i#R|0e._WM^eotg1h5Ȝ6æ S҄S>Q;1hrnqY:DGCePigTv~ (9Of*P-Fz9|j[X2pVו.JC6)R<kpm=Q%/TUts@Ն,Q>-yU�mJ5Ml7$MIʙ[p-_" ʛMh+Pr4\NN C(f^]god,J6ǜxn@ߖkA7mM"*<ZɎ3smJ͉cN<JեٽkjR^ X=ӻeUQ %S~_SP(}-i*?m`啛ܾ"0wwFGs|OyUwJ!U̺p0Dry̼N]ytnX:$WUZw0W/=Bepm˶U(;;9S'LټjǤާ}a lܖ¯gH);;fhPn9hxYj84+ ,دl7gQjUWշynOEyЯ~ê#S'Q -[r鱞ͬх%H|h0^]j,ԩ[,^y)GE_(@Q㞐4%)WR+e7PJ٤^M&$> 9݃~/nu@ D? qjd#�?c_؃|U<u`/F6Bl#.o_˭XRw&@ %50HZx=fOq>DdEFŌ@Q^nҟ)n-F6'U;K|tv((AEkb�l5T�]l=m .L͊2 AfGSr4l8t\6At_NB^dR҉r-Ab`= 0Ne&2wlDWSm2ePVY lG̎$]?i/B5:1?�$Jug@Z ̀+1l#d@)kfp8C%dVY zD E(m2>[J!?�݁I oL8@ *\7W6$;-GG⥰MWTY+>Rf%+,v@LuAV@A_7q&.ľ3gZ\`J(- 3"\ vUM}BDa857+,v@De@ @ Z @  W[eݩ8d?1|NKQ9 iŠ}XjMul99Z7$@ @@ @  BXطm?''$Ƒ<EQyK#>@ 7X kA"[SFЀ_!U&Wf;+o9C`_x0icFQՋ9_^ @ B1#rrŝ3Z);y#o? uՐ~*hMCgHƽ"vꍷB ryV]oPvʵ͊B 2oA:387>mۈ x(Mm9|{#/-!Q7+BwװKgbJ,r9eJ=lͤX<M"H2 Flt9d,:4wq{t z|;7^jw;~e1qMJ'8Y%)S#njx(%BI:;˷}dUxEW4K,̋^d+ML<eXt6#uc̉tHǩuk# ?]Z^s+2 SwEoHI7گ2+ /VӴW穝mI+l%灆,`ߏ~l_g-6Iؒ2*_@uePBO1{ Npat%S2<K>-0{Fо"FƧr8k'`[-5\'{_ND*ϮX6%%�'jvG9kvAw<č&AV@w (EnU^F#@ d Rg1g/Xhb~DW d]l7'l?|*e<!r>VN1[N8F sIG! w#í UOzMǞMJ*ׅw%@!9.O 3ŒNrP|I @ dR 3|Mvn52mس":nuDf]P얯7XҧϯrZo-p1-2o-͏ %1jf]AF]`֤� !~H g}Erll:~#!qr�B2tRS-\Z+%xyrX0O߰AZVg4r1fIe}C bq DVN{)RoD0 wG|akZ[c xLp 3zz.d-C&hO|0.~ .V:z2o蹺}R||_WRa}C (dCbYE?a3%\lc|SdBϯ:PDY4,;⒩l7%=b"hOт2oAV w-u~?­sX7w ?D+wLuq2JCBFNa\^;]4ӈ5{Gwc, Ar(a7n(n&h<=<Wxb˼ɵ!U"CEZq0%]^jcḶp;{EƠ|GʎSv)eҼzfS@ T.5*\w Wd&Z> #Jk򂓳G0bn.=uX0zSJ,Leg?HZVkO*N+znɚ]o2tfg&4&W|:f9}0-ܖCܿz( Y z~D2)prm=KwRǥߌ?T\/)Mw>DP:eeqLe(Tc׃e8*kʛ87B)S|iQDG1($9kmzZ5®I3]\`b Ȟ�y*7l'QثֶQ2O~0SH84Tp@Uw4G((g]1|pFiK9h?yC%8$ aQ<z Q* 6R=�t<Ne1!x"={(nKpw]dc,KҬc nZõz{UYͻǵɞ!“g(pȯ½ jIj<6z2=W<{- ?Pߑ2A Q-RAEjJQB7^j `̳@,!REEjiѨv-Q\}1#%3v@Ѫdi7wۧ#N@I><]319Lbw@jdf]6(IT x`ߖ}oc`¢uI&?M! O]0q wp^4ki Vt0ѡk[1̸$]Cs1~nݏ%W5·3Lu9rm#5ZVt*Tǩh3#`W3أo{©5 YnG/ƹ7Oж+քoү`Ij6nG{ӦTtg8o="̐7BބΟ~BySج)_ U])]#BjKZ@^@˄7(RbxHԧ:8Gt@}ml~I͟en_<#$Z+9415[v?ٻ)ݜ#~vqag96 |X,"^%ׯY|T"i ;A_pdHҴ+.v-~O)㑟KoNmv~?a<|Q)kzrviS* ܺs[S9l[(Ń17.3p#G}ށ+k.82mgl# EB̹l4;ˇh^~W_ NAURVii# FY'"->$ jmRRZ|ΤUg)(27#޻]gF^5o$TFwyǖ'c28YDž:7 =cjsw;40wg~8jgX; @T"ӨJ08JI0ܿ#s"8аr=) v<e|˓ ZQ%7󣁰Dr Ooz ipϙسz08DV-ŀP-NYoȨ|0y|Y8vd +tdđi͒لg 1$Qf9ud�b/YCOq*6 ͷm_w5fZǣ ╨Sq/;ϱk2|\>Irg{@=+c<4Iywc9=K&;3@DTTI8Do1 NE_ YuD^!th^RFr~L;^o fz9ˮ%n*C>8@!|[Jf-tHQT(W…иHI}ty_fLiRHh=jkzNY)<ȯ=PI�Cn%OF$+I9m}n5GOG,ƒ-Pʘ_47|iK3JצMMyosǯ9q9+!;N9C(<疞C77q>^ o@`Eg_20pw<GV޴s<Qjd0/&.8&;x֡8up߶.n >ߧV>|,:ms]g'ίBb-oS=''3H(2xDN?Q2xd몢jj}JxWnq9V/9rV=W-fdx:Skc舤JA~=6mgN JH^_:NIcY+򵚌׌*Nq=R<͂yznZɞ0oax霴E)60B)pLcd̰PʘQ<2+&Sq/>[oG_w61B)tg G\ޘ7?L6j2(zJDr=$3ѧ,οLR~~|ZJ#@ HWl[oYv/n#(iK6Fi`UZ 6 +ʬD,81Tbpa<mےދo1ݵChN'/9M1C)7mˡSx&9d)N)oC&w|w;nޭ|4RF/-ws$l;t zmZ{Cb@w 7fb/.9ƫ f,EX\ٿ6`@qL?}4,lTp�׹ٔ2X6ay%ޭirzmO˾5aJu;I6@kM5鶔BeE?wL9xn-r.YI5snٌp}lSyFjr)yEcy׼ ![3eo,kzO"w2o3jũ'MsB8Q*{|L HͿǼLow4A� ^ ϖAvRQi:˗ ơZ h inL3\TZ-[Ic_j92|`ӾI-khӑsw-G*ᬂZzFxo Ub:6(^$Mf[8i|w@t2kekLv+oZh0z;G3ѻP7Eɕ� |]gp)?Y;*rjɠ3\=~GEFwdMٵ:˞նSe~<-5m⤟/~'fa~?Y{?_mXtƤ (:g=bꀚ:kaKqOk_1oxsͧXOXt+ 897@wn~ټٿo-jDP?VsWLue[JQd{~IVaɒVrlYa+v`Έv4 q&XUŬ ԛuټ}f^DkHYcKZ@*!ۆM4̒75SahuZ e۴\3q=YgL ~5ؒC>jvQúS''p hRõhWh <ݳHMi:oe;NɭY7y!H=xaZ8V S"[P 7#V̾|KrYFϠ� {C]OPlJdBYaQ9^g㿏QP9/R&ɕ^n-ʏ(TT܅U�Jѡ2ԸtEOpI֤.6'_Z䎸(/#_aeymi#M kPwxp{Ϛv}*+h_/Sv|-3f.oDVt<9(^8o(O1܊UJɯ-8L?kAؔW-wJu&YK_} 5gsŅcc^XAϼ-e?{̋ӓ|jJ@Vx8NǑ FkDIZ*UJ]LrPKq*8la̸x\M<䩒(wދ[ȓ2ayF=WIT/n[U D],*Pƫq6> HmǽpI&D):mW["Wn@/`1\}حiEʍ;^R )Eb*g�9 L\2~b%~#jm*mk> 0yI!/;;&.flǸ*"E&\ԡ` ژܙ8)W@g6wף TZM@ǟL>Tnj<[5ַ蓻87==YYϐ־ʶ]+j _7CKǭC+WNIɇVSS <C[WiWI#*|kJU֘˃QQMuCGrE]MG�� �IDATQ*ԧDUZN󧹠K!G�4IaĪ|fL\efZ0Ll2 [ &mgW_G>^|5Κ@PX+6iLPkV,Nf5Z.gNK|nMi\U%`èИN .3w}p(\>R%JۈuwcY!m Bwj"gO;[Q/ v/i_AL rԢt7lKQiqs;oҮժ卓T1b> 0P"d,Qi$PJyQ`Hwĥw`Q)$^ Brv8ĄBDzT&EG%)uan4z9(XB|H(Q%m-KBZ: )U \lTo$I &ƒ1:/㜲% @o_\W(HTMN)L!*5AEy-+T_ ܿcr_3R@"BMQZO6*)H!lQؔ,nZIFkFҠ�u<VAcaGgPrOCO̵5ShR;9Sѱŷ˱ >JˠLvIrG&Lq3X#Ihˈ{ښhpTU>:ؿo#vEvt(iFf-6*ei3A]YGr$9ӸSS"*h]xǧ12.{Gr0A%&PS�%;e{N%)2ʖ ݱz)/N2[o&7<j)a"kkNlI k5魆Z+:4;k>+1l/tVyh4o7۲8y=Fu^$sdG-AQ.{YаGk<3QaDhQǔW"0È2߷Py g"Uٌx�Y /7~ L$cdUPϏS"U!Js[jx빾0OH嵩ԥnRQН<ȽHJ:xgQ5"Vmvo6sEo1GL-U񌌊@FkßUe>&Ď# sVDk%'O:d t,l堋HODDs`*vQa`|Ʃ|d"^cR@U\UHMv)Ge2<_fnǷV8_Y)M+EL$t((n௃ 7㕉n)*wUy/Hg|Pb|e"'yqOxS 2$.A=C(gH/W"O)oPB^W &5R6 Rڌ+HQѳ yWYO5<_DE bg•lܶc7508 M8 tǪ ?ńFG#%Mm)[BrPa/~o؃kiQLö q]g2(׹m[&* [niwPǐk3~-;bep%ZYSEFH<ڪ ˛*%,mDƯT`eymk# Z5u|B 'T4%�FDV?ꩌs G+ Թy׺km!2׆q3PB+)%dY0dW%MnxUPeQH8 =RfCB  ' \8e pJ:gP=Omq8kv7 dToԨ[Q@y6kRDuStACZ5Hifi5jD$ Rvg2X,rktٽzGښ-z&+xEDEjK+zDT4\Τ19Zc5IuzMg\;~Ȅf~J*6*U3oG=,9Jn!v\g;S11T!+[~93S{kJ @U%>x_p{yt7r#Z4 GHwLFh1dM)d^_=F3q+ISA7q9C<:.XùkTQ+7MfO$宁C޼-ߌAE.ydDpe/CӶ} \O{W;#^~*eL/MUӣµO,CT^*CП$[ab g1c vYKJ8%9Ln{IJuohTn Q1?/Y%{z e O;׮^ETv_O*fְs�jFҔnɛ71>U)nDt:OfI,=(>Ƶ\;NfE(=3i7T H-^ gTXu"Y49UTZj  \3Pj\-!zZV!}u 5%F!z., xC!}GQݥWBЋCQ@RT `AED(4E H!&WGڥA<#nvvomz#=!$ˢq}qXu6Wli~u^+mXGΪQYdy6ު~eh#5#Vwn2Kk\|r6qhlK׋ 0O B sm^W*6_lZ+slٲrW#ZX8RW5`W=웁S&1z]rtf{aئM5r_X'xv7je訸s8S-}63}jt{X7>᫥oQ$O{pڏl䑌+Y}픔`5Y {>Zz}"?LP;|F@Ex],̧o%txMMYnXȰdzn;goYOGksܴ?msI_.VYHëZM(LY7P dn9=2Kwfl,7t~R>}X+Ct4s%'oo~g'rKB9{LMؤ^ckSpO"(Yz:בg9\)ނ">rՄ5 <>ʐ)q]3Q�L `QQi\_ϴ`"R=/OFOӭW'Gة[W'Œ9m0e;K]r1wM?\%Xl:w&~VM2MG"xGDQ6C=ŶUxƱΏ�{i[ ܺtvc$a#T -}_U^*ý[w:_Snx=)Z-LIU0ڵ_s4ŝt,\p9k磭 !.WOsYVJ |xVmךq:tά5ػg+zv/b-ĞcxsNKVcM>]ھczD<ynn <Λ:ϛEJ6ܻ+)75] GfkI`R+X_[A;LW;1v4j)&>hh0o<Nʮmn_kUeJ5;ef:ʭԻ[AZ\_Ո)j7h>km6HlB )+KƹҤ>3 -OfuBw,ܝ7<PTgW lZ>meŭ<؞Κ*mSV1oβ\+ݑIMlďW5LM&0s;Sv+b&OJR9ڢ}]|yg9--o%SD~ݢqq/l|F.ʢ{9ye:e�%)SڐR.\ܵ)(ir̚W^Aܘt?wj6VlJHUغtWR/!'F~tl13Zʵ_л[kjTkJgV;'e=XaDŨqrǩT; . Wynt|~YA{7jwۄ>f0vxŋҁi޷i6`#ُe_!v1ѕ}9u7yf˾L^3G+p Ħghe TlPbr K͕A]NkR@Oh˧)7$g?S{9gxS"2 cRr;UUk U (mE>E 3*.P-aKH0p0]CI9'?xrO~ye`gJ7qyT6O>92բԸR|{[{B؉\\5O߭n#s= Fj*'kj5*덿9vzUKSEEq0g|ʔmLj ;ZA^]Z4#ˣ3z{tCzРm8K[FԧAsd^MH=E相dA3G~5S?Mo 0헄^3?3s \l릧hN˃닌b&:SvU/;:F (x)()s64r& iu\E!z f,RG&p3S:<UvȕS LBz羚AB>}VoNJUgܿ +[D@]xV|xCI ƃ0"C,piϊ^ԩqf{R2?OG$Q s&} ruI[@-00*O]x Olw/`G 8mVAM_A^60PEJ;&PgDaIǖqIMTK(WJ{o| Vyq߻u A,$2}yiY|ɣN\V6Ӗp *!hT<S2zOB[.:f,\9s[rE=΢6wkóPDq_k*l* KIfve2,\o>]tE1ߣhN])^D>:丁6);y/G.ZrƿCOR6 |u^~,]x^/xR_*緮j'6]ʊ/%=M=WfM4ſ%i> =eн5^iäyJuX2y49cX-isi֌/qٌ֘Jc"sV hjuzår=^# y/vaʼܿA=8_>;b6PڌP艪m6G3]OBRAOgl!ySo'q8k B xUQpK=Aʥ9Fh>(F:~4sTN6rz6VON\,*JbWV(@x-P濙ڹ#f^tI2 @q͌tGنJQuQQoc1jwEEֺޚ(%r /Xrw։0Pc/ `>732/}x{;[ 5T{eiL~_g׋_3~M݃-Wʽ 8˟Q\uj8>Wݷ,{+6 `b.Y@5a}t!r&0rR>45(:E~+[BW.U^j˾\԰(Qpр:~iquatj"CmkSc>>˲"iI;zdxnѽiѯfJ"?GF!s(~=˓2肩0l ujɺG*q ǣS.iv/Eacp(&}ylT%} QFH oB\*40_s(F1x;& rṱy3'"pFzJ*rm޵DTd¤1R)nS(\vݓX"TQ(9؅Z)$O8 ^V4t <6FOѻS Oõ+buy_7W4;n=5Gk꿩K{;u5H5̞e|<)M)ڂa_v|mZo1ҕz[`|X@CcXk|ķ|ܴ89V c땳~blt!<3v2o׽˽̭$O}6d|5Z*q{ n^,c|ѹv qx0`l/eY�*)> !*W3sNg}3lӇ>%}p(Jƽɶkq,у5튋!bhpmYE5N;:FLzw+^KǙ)iϊԘN_Fǫ_ J4L)?qf~Swb(<㏴~!p-_;TS?wt^xW%Û^=-ۄkQ/Iqiۿ5%#kp$ϼFӨWSor5҅Vdz/GT㒅 F.]ϲ w͛hZtW116B=D)b鷞((nv8gBJR"!ީ- fSG)g:|�[5͍xi;ԅ?󒞐 nP,TO)n7"]y-=! Wp Vy@ŮTu&4<.хjmt<tD“:|&gs1z?&va#FjxjCT7nnK;SYMj}>؉K5-JMpGL@dv_NyUל :͢(bZ'ײv쓄w?Xe_bT"'.)YFfuVvvx^=r6eԞersçX9}a�߮wcR(?>KO^7*5c-GVplWaOޑjB{aOǧذ+y"F]tYԋBU4#UnL^fҭA{MtebG iqLTO6G4b{Ƽs@bG:[M:tvͮ/짪BͼQ3/X�u^c=_&�wJR9X1^oI*BYFYB!D (ԸMK{kj FuVMcY^)SL[ԛ?qK&N]1vOj.bO{sȳ/w3ORmv' SKFmhJtV<2d!_T[,BInj",\߰ԥe|"9'neHG.կG[! z<1ϧ(MUp+ېݞJa\{0DQz:%̕SY=y ?\J]#؋JUKIQ!# !M0$]qIw_xt?F?tQ�ecL跞kPozu�rDB!I^ !r~Ƥ=F![F!Ľ0)}& ǥ<"0z�j簺B!! !;Ξf@O@t{ !2syRXʆ=_9.tX1JLԀlB!cF!B!A䅷B!B!HnjB!B!HnjB!B!HnjB!B!HnjB!B!�!RYgylxR9:QBQH=)AJ6B60úJPO�� �IDATm矲av:r&sfMdP ۙijsGڼ.S@h'^-+ʫ\;#؊Z -KGs\/uN}o_&:bvtǕuN+T K~SA;dQ!|(WΚW)Y~E9M茱4;y>'3p0cI}SMشX):ʥuh5.7ILc;X8ێfMGQ8BOIaWB<cF`BYFXb+q'cȭF0\;wU'+QHL !@1# ;FC᳁(NW!d:S7*vQW ĝJa(;_wogІrNSO>`iNW!1)x�俢]RNs.u؂Ku:ц: U ɭR~~RMBO'~`z%ԓj9*hĕxPHL !<٘Mզx� >%ĕB!!/MLm7zDPb}6.;ϳj8U#Z5[ 8z,_X3ՠېxj5'I2ˢ~(WώakNdw܇[B/YG<wK*^4Vܶ}~WfV[qlOժBXhw9 G2ŧxii \zm�䉓8qLFS?Ok?ž-exq??|ס|B*QVSھ<_:Me!8Ædʔ-GźvʠսVh lk#&1ui. S?ğVwQuv דKϚ웭{˒f(pZ9+wyUFeA{'5o^6sƆu{>YCɖ bX3%?ٽf3̩W5~:vcW0[~Vs\|.DzqSSq9on|?�Uq Wc':t='flZnq)rxl_Ieb;ıL]Ɣux߼gxfA%~ۧ}a<nf-˿ϻ!)GWl9E;2īI;֤~BYzFRTwO{|d0C?/`t{VXNϲ>mظf}oiDhX : :_\irUoe$l|ƽe'Jn#NB|Mh +y:ylQɈlO̠o7 T#g[?sz3+ 9?)?&+~gڔ]�9txܤӬxoyi|u$[T@Lg>.v8ߥL:{ƾÄ?2}Y3ru'_MNNW&F,WvOٔ8ӮKlыw6( V,.eUՁ9C;ݛGA'+X[8e//Ĥ+[e!2 [[%7{;]#+yn cZ^Bv1/aWʒ>z}F1jl[ٟb"Pp)RHyN'm7OKyS(ۚ!-c|; }[ S 4ŕGbڤЇSZs'+̌݉yL؄pה&g 9mq8结o*<Y^?kj֭ONyh50sfr} L}0Splɇ3aw|\Y\9?B(P3'Lb@S/^Us4eS.K&-Lv1`M(x}"6ϗjO 6O2|qW9.PM&,.Ĥ ,ٽsχVT"7#绷{VVxX㷢'%5/ fpW}1meX-۱ٞtDcC=oB!}H>e…sޢZĤl+Hݺ9JZeϐ&gDŽ&+QU^tM?<) J"# MGЗ,CiJ?3X⹚h J >THSs9QU6]J ; \bY/F2tzoEg?pT%t5mRP>+1T97R<j3hDD&_¹s)$i9cKlP\(yğzmwT!_Kg{\KʂNX_ncǵ|UgRuÄJ9`N[$,d 1 P=:[à_qu*5U1 z]zwکukNAC:TmIaD5ǥHqJ4#Ӝ?ijFTRO:f+g:VY8 v؂nO|9cC=OyU5OɈ,/Ns𽙬W$ZW+PNv [ǿi5hE2V)4|1ESr]g_1b� z pIM[WVsZWTi8)L5H# lbA/eRAY~*Įxգm= lwO7衖<e=ɈK-[VFQl) A햝xoXMjɕ8y4_pZ\Φs%e!xqP|yuCR'"U1<}үŒs2UgWQL ;%5, yVO:<U!i3ֱZceB9cldr 6d[ 7T\cwx~p Tz҇>UuϕkOd\BKeb #T,Wip\})So۫7nO?,iVTtEC(aаb9 Ppp$\OsCpt .}Gb&6+gw`<G/hN~ETBKRA9 :i\1]Lx>:M6]Ѣi ,)/eߊ#cR܅-aY۽LzY) I[%Oզ$()c#<}BvYAaߩn\ŌOZ ,V~ulWt fMx{S dRKKFF _VՄ^% ;sڃ9C^ Yטb6/ �ίJzolRv V+ :c\);mԒģ1k.ga{f4叾XqT$TPĤ [~e^2pUXڱVQ&y/sFyʫ 6(c&[.Dv¦zZ)F)DN.z~ݪ.ų?M9'%-e,^1=RBDFRLl1&q_22R5,PT"v/{ r/Bg$2HG@t,2$s681_ԛ옽?_|J}Kv]ftd[X9IKS+zk_0wc\~fm&l<ԡ鼤e1%VyfSIQ~˶Yn�C :*@O=u+k%sL;ytθҗ>tRcrrbʛ6=Q4Qi us|,i%+ 姞t~y˫VkYpT$d"`{U׎u5rgi(A ! F!h;+>JASٯrs8t3u#?So*Ƹ3XX1ŝ&l(ZDv\Q-k_W.Wxe"~t]pm>쏂'u^CPcc!7ީb; {ذ\5{O4$\kŜ =iR$@%5DŽE.ej͚ΝIg_ 2qAR)OlI8x *~1q+96W"ˆk1UL*^x!㯧cr&S8<p4-e'yʫVkYpX1ctsz-\8{>Omܶc~ibÆ4A ! t@g ϐ\P (Oz5hhL J_aܻ?Ue.u+O2Z<rxRjd,3skU%rm֚՚{žo]3m&y~=1%/K_}+Ya}L'XH7SsTl._eAMN:>is�`bľԄmdU.ĕƓ!\4 T,È\eZ,^J~ЗB9mULKR!%\ܵ)u[L5@*11IilK<ۧ,^6yU*y. c,{J਷w|JVk[%X_#Gqذ%ͶxcCRZ腍F;K;xS 9g)cGxׇUo@͈(LO:2 Џ`^\Rs> i6Pe *9! S|׫+ NUxeWдW;8t<R'$}2qKwF7ȕS\q$b&q߿9fЃ'4/-z]V6Ӗp *!hT<S2zO;*6laCYP*WX嵦e''bx;o{R2?OG$Q s&}yruɰc~cQe `X|�*-_t7r�{YZŤ#uNU0Ā\es'NUѠt4* f-aU\ձRm`Ē^$4=+{m_ fcmX]#G 8Ql?Ͷv,WtdD*',v fSŝbjǺT[. XPBދ]ͮy+4*w24&U|q_ڎ؋_3^jG:_omTsgePI=ovnƈټ6eRLPi3/?۾M[z9NPQ}l)v\[E&?[ʂ+6 `b.Y@5a^`ON^z~3#ofd_hvtTtSjE|uR&(W,A:*r&)k([ZSQK<.^*[?Jϭ6Q ؋5lMII-lp{mmvJ˂X]HK4ɖ%ɟW[.Ƅ0!hwiFjsذ%Ͷp|&2eåBONCwō6#ػ+F1x;& ṱy3MD+4-7-NӷbqL29gb4fJz,o\Φ8=x0`l/eE‹bv8.6lb�\u\>l}ּܶ$.Ӡ}ru ӮtTt¸pTTb(KrQÿrU5$UT`ؗ(]_g6uI-lp{Mmͽv]QZ BPW:9W6d!F4aKm:؃Bɖ-?aՖz V#BT䑖h~Kd6wEH gWiXMPD4-όCWz, o\ "K|2-k>IX uEG=X7g Ggtu‹xBcHs~dGFP 8ms~ )VG_k,MBh=n1=#Ep&txku R7שT/@\J$gb(KPP!$!@Mf1#V[b ͛ru}g*kI-U#<i%Su/p}mGձ瘼b 7*7y/퇽m1k(&llaޑ #>lOb}yԺF!B!4"#fB!B!D:fB!B!D:fB!B!D:fB!B!D:fB!B!DVeB!B!p1#B!B 1#B!B 1#B!B 1#B!B 1#B!B G'@`\ςApstB!dAڼ.J@h'^VIWvG>~GZ %~av:r&sfMdP ۙTbN}ԺbMuĜ~Fv}Xmj}nB!"#f}@ҺA-Gn?^8vz-jz90:>6zԫNi1LTJ( g!B!$2bF8ĝ03Cl:{Δ_�]0Ŭō;.EijB!Ntx#O<*nT:+&У @;q\WhᇛG0ʇ8:]PƳbuvvΎ mB!J"Nws\6 WM MnCHw0e::!^Adz'O:-B!B0y\l"mQ/PM,!EY!B́zu+yX0S>13-Myv|7^mjdBBQK.6^Au(_*аJTՔ/`ሀ#KSDGER44G']/_O>ϠtOUx$IYԯJ#1l 7?䉓8qL??6O%<8r1٥fK:5Gog6dž-5gyjQsXv8!4k{ؕ?5|nĔжq/OhH+֧ o3z!hЎgÚWE#^a[t> !BQɈ\RgL|+&;D=1g0H-CŨVOCOc}?#f3[y336_0Y=H!UEsX0p:߽Y ?%Ӿ> w#u[_9ןwIX6nj?2pݍg 6jlDc|ȁ/Y'@Mɰ&j #3L4cOa=<w._gKr%6lbLR|^ur鑳Թ,n1tjurȉ}c1dH<ݫ{fS{QGs-\YLX~%CV1P& <2l|B!p3rj|;c1CX ,ߓ϶g|@ʰWiVӃ+x˧l)i_%Ż?_ɸ͝]|ȺN{;`Poδ)p M0sv<%X'(0I,ۋky ӼO>z)4'=t--SB |Fg>1vd<3kX&vP^|u$[T@Lg>.V8e> !Ba'1+F{Gz3i ~Չ^ !jmgJ6ΧΥ|?#/g2 o~դ|!ӆ9`Χ@bxVeҠVuOykl>ǒIK9c5;ڡْg.c乲sIUT5c ]ÒPs'1 e[3elZzOIRb�� �IDAT|3{<>6lK iv|f,aㆅL|]RҜS?])s^7+h{r?˗+ejEL>b5[cOxft()M<8mW90DZO>ߝMɃhaxsB!"2.iF6A ԯ"ќ(׊QVK8*R-tlH%oQDlʝְrW:)NX_ncǵ|Ug"SPyfcaB%q0v' ȁmڧQA''e¹s)$ݽsF)Bdd Ivpe(ATigK<WS-k j}%+^Dt[pkȗ9cdL^畨伪V: 4'3* ~fgy7.6la5LM[ 'P)Uw[/woV厅 .͇ k;O<R.mRV5= W<tς1x+3g!B! '1+:[<O#e@,SPp՜&[Rㄧ )P9b5?-;֧߰Nx+qqi &;Dɿ\L塖<a4E˖ p1�}\ a˜6mih]*g_>Ә)CƆi>Oz[ѦL!<Խ?o~_á?gحSWk[zJG:0𽙬WD)LʫLX?L[*(b49Odsq|B!~ٛ+z"+D{LK<WSF$K}s^l]I<d~=zDKv\T7HC4hpQ{4:! .%ʔ10BpXq26la5KZ %J)ͺO2pX6jWk[:žlK῱ kO^VV]*) ˫,iV~q'g!B!H듋,yCiҲ޳=CLf ji1If#dLNM ]1a .`Wk{aPtǶ/ftx´OqyuTodud9,B!HL./jg/̞B0Cȵ>_";NacyM-uK&EN@z�f ~֒B!BO4CեVla =~S/Cse[BL `hDGڼT|e-Y([&s-̖?-(:d7|lz*)igsGLYOl–keXxΝ82뵜zoRrpijS fn+Ϭͤǘ:t6LyL,B!8h}C|(ʖ+^+<Q{<B{2!R[̙Г&Ji�T]S _GQSgx>pe:r#_fc.fSi4YL,gW1j7|O.fSMr,2… ׈sxG0h*3UN_t�n점ij.6MG:11ks cg!B!H:f4Ii+-fk뿠wԨ֔+++%q')}Kocbӎkܷ3Nr,#lg:'*Jϫ:~Y˩SGxR:{4:1Q>嶏Sbw\Lg=#Kw{_NFIʣv [rl?_?l1>CVr1C (OzQ93]3m&y~=\,B!8>c|TsCF̧WW<'6ڏ˻06p ˦ivD(+XPIe,}GZӲ\ՓG1vǝ<P^m|-;UCzѨx; e$;Lk:J-Q@t~eh͡G%}41_&0PEJ;&PgDaIǖqIsTEGExԪK elKzxu}5vȕS׍gҖuFMs̠EOh!^Kpr,3Ώ_N1^ԞkZ>}=ߎkt7{f`oJo;zY!B*w24&U|q_ڎ3oxkx&8ÕZM0o1,ΰzxOVq}t!r&0rR>4`(:E~UT[. XPBދ]e;Ž2/oE㓌Kt^)i}t1:,βRh+]HK4ɖ%ɟY.Ƅ0!y濙ڹ#f3tI2 @q͌tʶ\ի`^;K<{Ho97ŝbcS<pFՎNu`ۨ8\ˠ)B!"2iM oiq2cqK!-0y�:hztME1 ۋjټVbEׁth;fod�{;7 EA3c'vݻ٭$O}6eZZ)J!ChjȲ̵7^'6la5|]*d<{Qq#&.G0Ƴ͟R'X߸M v|B!NckMHfDy_H1^kY;I2?mJq2jOAxS0o~ϻ1~Y4)?>KO^7*5c-Gr\+q@J.]ϲ w͛hZtW116PuX(0pٗ+≫;EJVQv3=}1y4An!Tn_.{-(竧DOXe^mãtß2ye7>_YųhSʼz�Xq|B!U#* !B!B8B!B!pB!B!pB!B!pB!B!pB!B!pB!B!pB!B!pB!B!pB!B!pB!B!pB!B!p+X %::!B!B!pvy1_fO٪!K&$<k3z=k ͎_е^ :avKr~$5wCqwaGF9.x(n Lrtj̴ UcTTeFEUcXl8;*3*2Mi g>GH@(+A7QUJ`d^%mz }&xȎkJSS9]8]BL LމaLk9.~{7+_[3g2?Bϱ9wZTpn\8ZW!B!\{s_ƾֱeuP/ƨOYzmrUo\U]{m N!Xx~ݽݻ{>zщB!DFX,bw'1Bg|RTR&JCv'y:-lȟS7!1c6;<9͠k:Mz"|R@G!B,W#f8Q^}IHZ#,N5mX@e_[hq\_W 09SF||p;74Φ#-;uNB!Bkh9{mPvo<(_9=*渳ę6Wg .|1A\jOc/1zq};kY>vcƲ^fl3< &@zBB �7M: t"J "H"*Hi@B(|?RH@ݰcLι9{(_;콋*&@ C#R$ж$mBLuBUzV-k1IYٷ'U{[@}tu?wtxזqkg?;&_j#I86R5癤 ؑ( Ϳ6q4 ڑ̮ٞvkc9x#Ă:ꕵ/=qeGOuL"u,pl=zuWpѼ\k9-rOC/EׁS[GѿQNpzY=z%?ƓsI5k/^5Vt8y7ֈhz3&D>88|&c0wMbg{L*R_k`͐߹x!uwWLas&Q̫FU3>a,[)E2㣮^/I7fai7}Zvl oQ2,j,Z嚱mMTm. iί3 nnz|~Ǫ{L*Vj7cE=LXKћ"gi\bWnMF4)D{Mпe=JχOИ/Gч‡9=:^1|l%0,wPÊZJmSJGpˈf=r@%[=SV,ӏ*om@ofʳ׏Wȁ}qsN6+1ۜjY"]MS,ՓOkUD@~||~(3v\kϸy:[@G@i;p&{on�xFՙNjit[<9P5#<=s&M~dSZGBf3�4}wƺW/XxhX8>y_]ŵ頤@ HwRusUTd<75}RxdzoQ󛺦-k嚸a ^huiw*[g䐉,;ICM󢸤2C[&IѦKBS%~Lgf,Wf| +;tTp&Q*;%n70l4N*|򡎝^ޖ[aZy] lj"gB:6N%d6W8-dzZl i!r3Af/0Ik.peϴٱ݆0oC[/mK)uh~~,A7z%G%-9ܭ'$M,A+WR` >j7;L ޞcƼpq'4Y}isagzSJ&3\q+VSdkx>b<|7痟#J`t>qխwx/FظѨ[x鬡oZHB)H1dpt(d2m9%=QVtYpìtuK60|*vy4zq)/j{|={9ָ?Elؓ!v[Ceya>e]C9l :%Ki=mJżhf6"rҬCݶ)<?m$0XM0F޽ỗ8iK:NedO<MKOXVWOl] 0BM<<_,zl I [ê joK;}fq.9a*]q}2ʔ$ElϾ"mwI<q(Gwda|keEAc{F~$ Maʘj-Ox nN~c(MƥwHɞ=ň N][{ҩ>ŧC1eCgr~jzLEdq7fE{m`G6fJ9wP lcgx2B 2{*.2}SOX<D{0Yǩ$}m5>cdK`gNGkojd =P6H!9k=Q>e`"#mLb:~ n*h_.GqrO0aH.&f]cf-z^3qdO|V%&;'uz YzuA{qXB#E wCMI=}9p$$Q4N$5xF8Lܛ#/$PoL}_^~9ܮci^7+><ݼkQŎ&'2$B ~#i7mud}Q Y>jţűYuNIOTMy=~0cv[Qm(&> dx}0u궘#1GVs?bPv ṯF+XuY%3V]nŗH`6.ܖk&Zee8.߳ɩ0MOfmۻK"NH/w_^Ky =r]ՑhEY);(U$"6f-OyȤ=^|5z5*ǂ<ǎ5b-qC =ƄNY_ckܓogq-dzdz>$~zK k8/r`[-̾=9%LO3t$YXRMjdm߷#[PE'8TpF_HSYEr|\?&I'wff:d?ky 5龐F Y5fT՗zU'l KT{/M(74GNEd$v ^^9)ذ|+VΤS!+/y D=935}&xS:<�FlJ-BfX'&ijd2\2F&jWW8<=<VjNi2Q>dB f֋VM<uD6zȋ3M)5@it_Zi5h83̅]^IK-kl$E_#"ZE_�xȔ|Dρ{VnɠуhW tZHF0áq Ppm3ƟǴ! +I{2[;bRWcrxEs.fĠ:gpy韅K+SS֙uF pjZJo9c8V&"gW2dTM޿~t}^`o[p/s|6JLoاd5 `ΏQ)[l8yGAP)XLTBRpMʌݨi\ 9CӷR!mrWli#,9Ig^JBo$͸Ũ|݂UtA~rpL'aNrTHYGS8}m wj߸d皝<?GxF0KJV![-Uqӎ#RƷaGW*EŘ7$Q%.Uk 6^25y)n.+R,5jƾz:‚q`7.7үPvk!Q-*=MY{~j&x%"A-vnķ<^~xy$^w/&SVuZ=72qn (X`#miW�1qf$R/i,#kԯ ^_fp*Fxϻ""fΔr86kNJ(\,xa霽KQ˜6RV; Wl۔eU<|;Qt;N(h-|5d1[;oLuZֿٛdƷCwF/[·eR?ڦDz� $] SVlazЌN{;ˉ9Ψ#37nA/Kb@fLe̿UVP)9ImwFaFWd)gdpc58Y69KT:%u*ms&=;dM!bt7bq�� �IDAT'Ahgwgнg(.60fҴu H}!V.WvDXwdsA%"{Vbѹf%/.׽ʱ-΅kh.Bc$ZԚ9R5JU,{M/#K�S{wiJ=nҌqيhVH2%Q`/lZҵ(7i .T=G&T1ruKU"B&^q;i.$k7bӇqq5Sk|[ FTi!~ϟP8JWP1|!O$a<w:^ג:.GM7W0^؛d%++)vWA_60űt75pF"9>gC6HN,2s#2` M.+ ptTsJ:>OnȨ\9u79D'Yd s̝ۙf*+kRLa!�]jfM?q~|^cA\Yi7yQ&kn#WkAMsav1Fht}PڔNaIY~%{*wF=iK#9pmfA9["UM2d)ۃHm4Uԩ@@@o>3D#ӯhHaٹtjЅ@˦<ˤ5Rfߌd\=gS*Wu[*#Xb8*Ieic v""˄5VHK%֧vlaŽ(|R]+MFj6d/U B:pA⢹S\FZ Dղцۗ%JkQ2sg$cLoR&4<9 1Z)"̽; y4r,yA}*b{9.܎zPJ {|U':qi_1O@+pN}?{]Bd <5-@Zyt:O3̤*m|}\ JʊGU=+_3#XRu(ʫ$t(q1SٗuQa8onCrx?"É|LJԑ,cG޾cftacCIQk=`,SQ f/|B Eѡa|w6Hz=z 찳�  +FWY~w<WdLHhh"''~m>jP{f/B57s/mYYGԡO^Qs)lf.WVZʞd .k&D)s9jVH'ԑW|YO3T,倄;Wo1lD0c%e\"c_i(Ы 3^fʮ(Kex גRF8a&.D|)YZR'Ń�%7?.T=IۺVqB.uyaM,<}c;Peq5:?~M %y--Q[bvrhJ>Ih kW/ I` ]$F6OҐ0CrnMpG=2Ѧy4r(PL#wa8Jf Iy 42Jr4*r-r#g_qPUCrEzR}12<+^øk0sbW;p^dANDH>=E}_&KvO$.EoS"_f,R67,g Оh0q;q'(cb�dIՑM_^ ѽzU4mNZx=>;*<yfg1az+yنw\_#ްek5&DZ$3 )BA PV\ hS}G?ĸƔ݀fKS wNrȉjn\WV^$W<婝|(◢SԑWqˬz=y OM5vmrDn<}<p7[G2nB;}? eILY{&SV:ܝIaJ@!Haȭ5hJ"!kN\2)@$%V'aaxeT+i˝ ydlLi+}2Js<CF[{ڹKgaO_Ӻ+,JfLO@E)|ffC�5fxQaׇJ/zt$U^hHxWww #9hTBehY̠OuL:"%5:V&52aӶp9Bɑ| Èϓ;-WGJ/$[0(J"@̈́ɤa^*&lKexwk.^/MՑlk2{!*hᇹz%_W<ej%z7QcL̦Uٯ}VRdwwU_.Fi1y!<\q>3y$3wۮ,7->?MzK[ʹc,{Sw ]SVL+R:N1D) UM6K{9["7%u$)r\ 0cRkue(e%U뱒H XV,Ik 220GǜyckZwJͦ#[{Uryxd}UWnr<C"?f`IbgoNQ|M>ŪQ* ϷDjZpX¤hm2{g_E7ZAE\-FiŚd\6Ձ[7eor6(c"(7¸4;r%=VMzA@AEŤxPy?-]ϮbcVk7 Rf ʖ]+S9M 27 (92u_ږ 3fh\.`f$?D$C @ r?Wμ�]V?哄IFqjμsaoeg㓍 i{hY#˾om:0hʾIz芩;i5')Fo46uZfpc7K<ީ&V~c80�h %9Qrn52Gb$th=pCڗ3;EAolW?+jXus'bQ=/ŗe(#y6wN|Q ^[OGeINML2/WwgƀVJB)|˗0.'x*a;y+ܗ{P-owQ_é?}$Mvu,7xe>d/j ˜q ϰYk{.dĬ+6ee}-@rGh7VS±#MBu=n -+MU1{R3lRΖ:ԘJQRld%++U}6\A=fL]19 hJnO:)-Ik i+}~3ܿ |gG-F1|d7擂Y]aTԅ% zf_ĖacͣI\ݣ\eLXc`x9LcD@4KTA1ѿcR#GRcHb-z{%Т谿y.qģHk}ơS7>ޛ]&בL62x$Fs2rt\m';9v!G ???=Ϯ>A3ny}q$J{P D$*.y푝?A<=$x[܌;'?#odΤ�O-ԑGzqљ;ٿb._pqvsЂYoo3D%s^ԉ<uk ++O rto @%q}a>iY*.$û>MIBت5g%G ZL|W3StY= Q23\?οj2F_HQd<M< *�@lV}A9UBfV!e+W6,?3M"S4$\M~5ҷ(fGTȕZ00=9Sfh8F$ ??nՓ9{YS J).Gqdo#_$r] kT"w<ۡ~cӷJV }KdީרZ)hZ;ȗ7YfDC o֒xkdAkwpJEe;yr3SRq64{o G[hs+fXgNTnԲپ? ȌGܯ"\C/x;[zeVflag= g\Z6.%`%plnߘzq17v.ߌg)76`Ok Z%,qa3r+M;<SG Ć6"&HIw.|SlK@�0f cΫFdA{ 3sT Z1-RWV1ǟ8'3>g z+mbݶ*7yry]yXȇC> R �oL!G\YEr^?MtK}!-l q 1Ep9 Hi7٪/HEӣpc?O>(ҋ}8͑34IOI7%icnO?wd̉n@p-V@q J F#,p*@Rʼzo*w̎#hĠ&ӿ)lM /env(1{N"$~}B2wnW/(-mw^=w1*9l!j4׬XK�+$7ëdn yjTS,۵C+I@ַCB Elw;?98fa䙋PpSUGxx<7f\\qY:.}#4]gxt$e.S %(x֪*/NwsGl[ HW5Ybڃolu_-|K҂H^|+BZ>on穝Slx-jx+`΢Q x|~v)?b ɌϺB(( ks^p|nG ZZBJ+W$Pb.^;gzR>]Kas`g�+)MХzi jʏ“"/}LHdɁ[/Q0^aUL=õeLZv +%%ud`I9[«:?N"}[${Ԯlx7ٮ/KV ̟'`鵌f%RC|=Lnq'Rٍ.\ɝ\>Nq/$m {ߓ8<-{^c{^e\ҧ&KzEȞ]l@IH]?t^©LSJ12++F"4e2%=eBwQW& 3o'#T X鸬[#{D~mbGطFVȑE%S~; ѬT5̆'c?*PKZ0f$3G,Zr%k)qdvvFՀ"atG vV+9⎧'2Ο|F?No La;~ڴ=ݢ[\Uaʻ4 q_-e^wLQ.֖Juױi#44 Aysl(ޫÃ絯|y=XA^JڑzE2퍣#hR?&Yݯ:uZޓr Tj*0v!q<z>ׯ^$|_ާ28^藉ˁ,4%_Ώ6߶M`/9O|ش.'vpWq֖I|7FɎ[R&˜VR[VkR9gJ/ %2`2#=N- ;QoI?QUM6*(F!W4Kߧ0tF-?q*tYUu\g@)e̘0PrXzXRG6_2U{|Ӱu➑\ \K't~-*W;osU_ke+?6̸u}iu?3.nŨI;18eB {aU }IܣGaT˪,yͳv~/)[679}uٓȑ#ف229[34<wvMiRHsc~͋3h^ ḳv*jd9=gH۫}o^ig`\]+މ%F.6ԱĢAz%~FU/>*F'GkfdB5YSnt 3~zk#׈=�d+}k-ԌEһKu>eErT?͇2b4 ;$(8r"!zsxˆrdU4Ȟq!}8t"<;ȉT(fAG¡N/<IdJYQ=gq&+Ց[S{<f+YUFw$ sEWu=#'tm_*%.̻ȜtŒ mry7f%xF3fѴ=ؙWX=3^qП= 6ZZBJjA8>Bnl9.z|adzJǙ^\ٸ4;yܲqJۦSPAFp0>Y9+%Mbhn _L7O)Bv-KYcheVeI٨/XTΖ4mAWu2]Nw ų2Jui:2Q~DzɧruDl#WƳPa[V+ғez˴T$m|t%j\j;cٛҭFnODʕ;f=Cbrˌ3*ҴDE�[o-բ?_8@h8=Sll@6ל5ۨo`Cz;#1d.D W[Z3 3F*Do)BE{8Be% `U@abt°\) D?dLj2>o+}c͡^> b爣Y DȞqe.DvrO,#{<5fu;Gkh9K W.K&9 iZEV2DT8\PijZDlHfbG_zs((6u7";z;{mғWΦrt<i:}Y6ΩR|r%s-?n#{s{f֬S`Id~k$ϺLزSL-Ko~g늞2XI_KH]Y)u>[I<xdc svlF4ϙ)[8z4^}sIn[Uzz2Ȃ195J3BNZ?T3ENɛٳ` {f2㑧$|9U{V2�KF}r%;O̮i[(2cg|h} .W7DXg 9gȺ؄ 8;W&fyQ' 9\@~Hf@ oNic3r^[+@ X p@ GqJtxԮ0@ 1aF  &ؐJ^kg̣H@ k@ {hc]F[| '@ EaF ;ފ=$P[F @N" 3@ xОTYc@ L@ @ 6Bx@ @ a@ @ 0#@ @`#aF @ FÌ@ @ :A\0xVD:مP @ iwX7<寨7? zӊ'L鋛)%w-CL}YMN"/z,I._!t,n)@UZ\�� �IDATN>ND ӥv{my0x7zsʻ֏ޕVl@13,Y %fD �0_X|F௉S>j| lh~$x7P#qb7aOny((~tZ'/5PO1QOW(?x54qGfӿ=g۽yl-~PDD_4 Ö}Z8fqD� 8:Ohg5~$Dys-ۏ$ܘHB>i Kg�}t K޼^)~toLd&OZ8SgcIw7}_Ly<m3QMo޷u6XgNH1p{4 m7t\#RlrfG<u#vuX;a]M|Z !FAB^HPt3# 9ia2m# Cp/|1t3efoZH斍R֧ '2|X8ӳ8/R8)8H:1'vo@ ,F %pXKZ?WٹGښ1$A eÎt>ɏMp LU(] B;~Ake"% iUȞ,ڏfՅ3.o-Q2>|p|9O>4;Mj;1z$i(aZ#O.);9P;^OCE}tecѢV%r퓟 h ^y6<{Ѱ\q|}L#k/]<mĚuXҏb1b騞|Z*%㝗EC*JN{$kϦ3,݋wsQ|i4"ڥwsk+dly:[@G@i;p&{oG!+Dtzk؞>hmSi@мt.eX5N}^AE.3]J^+LF43scۜ0.zKeJLǦ[eEǛ}_:̪KGXd×+7# ao}?^x̘Ea|o.;:r`j~4'V=fA0M!b*l :%Ki=mJ2iӶ;$C3QȞךJ _x~74I``y^e,8c'*6><?N-gC7'V l%]'sSl7[Wn q5RHݶ6A3l?m1ksf !ND@29&*pLre e)E߹\ /6[ " : u[̑9~ #aH1pbVd3旁s[-mfߞX'g:g \ۛ /{aƚطPMF}N#4yff3B0#a_]"t3׳֔r!k a=_MڑvO}Y0%\6O,|϶$4<ozV-͊8!=}{-q#9>?$ٓno~]{W3s@ň5h#q|;?TK Ab8˴ VbtVl}Yp<}!sz뉼6Ou]2=(ո+Obژ|aaܔR׿L_ +L<ֱkzcA؞WL[X<Uuhǘi0zK-H؞>hmciA eqmΘa5p�)G|B[ЪE#*T7њ Ҳe*UhǬ ^Ȇ]F@<c vsgd%_t`zBחY3JYE=v꿋s;Sj1xy_$$lӄ1+BER' >kK+U&]EnFAWn1Ș‰уkS&fCHѲT/ On-.hxmqlל\1/.\,xa霽KQ˜qmxn /tybƼ2"Rvaw`ѭ#,X/y5 ?0h2^ ƳqnSr-V skx5ڈU Ab< h/fBx4)EjQs#<Gxoq$4ϑMv烉Nqν6u!4:/7}}J&)<H*sۧO>{Q_UhjUEp#VIs?ӠEs妥e AMh33?".czûm G|@Y.<G9``F+ۑDNE72u&O(RI/${*wFzZy.L8<7 w |>Q܂#Y_xl4 rպ~{FNH JIqmRW63ĒlwB"9=0Aԕkg8}%P OnȨ\9uQf]�$(LO%HBM.ŋdM'{C߿)_(cR=>T}D禉L;9(,V`Zu1d h1WF ݶi7yQ&kn#WNhXbҠ%sQӼq"#g׮|ӷ7cpo_0 Pq-u55 7`p*hֲJQ4tJMJ+;C3g@ LzSg҅}q*r@ĝ7xuFŋSA(CsY(S(:4/pw+xS=+9Qraz-kVs.Q,`foJJJEǴ+ڤA' Y.Wfٽ ȵ+JRq7A'Nc9,ĘYU3)gqܴ8%Q 9iy0 Ȏd-HRQ/�)sgg[ZĜ=e;OeɎ\\_)MQߴ@n1pBn#,OL� ʔpT@Peݢ{+O-gT_J8~ƿ9/+MQBqJF32О> �&cdG7qLHIӓ0J\;쎇rGFUv7I7ͶwOhaۤoXβ[}CLx?OuLF7w'dmAʑs5M S~w/M9ɑ#' +k:zbz|^qBn'J%2TF][&ɛ+cjL;niGhroI42:a<jll<m qVA҄%z�rS!n>.O>|~GN)[T/Vmc.P<t#{lqKſ=s~2x'?J6)7!6tCF̤bFUMv+iUnk.j23S/W.!$3KJLū&Vm QJ}!.FXGF_X5 *-Ǐp^,V3?KTloB$8Tq̽uidWU{̥kt4ҤC:l5˛l'=1f?)S:WK /l `s3bҦoZnM%ZO# sx}Zo\?gA�uQ�׹;C=J]n`rt^~x0! 3 }P.L?:J5iMS'; P{bs,g Pr6dBwm>[x8HUChx }NܣkU2 .bpU N􈔩�' f}refMg:х! Ǻanաׁj[I StM ݊f|=8s7@Z e" ޺}ٹg�D/ToՒEԘ*gĴIMU1Q`@1G"dWj]ǢyIf(o=MA*B?Ѡ8)waؑL&!?ӺFkv9%=qKH:2+<4!dFu`/MzY滈Aŗeţ!ܻ/uhbT~ g#Oem<g=!HoKwN쁗Mr44\ cWm AE Lw0V嚱  ހY-*ċ78u^ŷh-]89#<TCDV/0<>CPx%~LN.8Ye &CGG1r]\q_VAiH։X�*!+Vly\Pi3oo*(1(ՐA p?g<k;h!3Y=qvIp(/̸y-tyPv?+r`hq%٫xΏ|ylQ01p%0,ĻVd5ld~JY)X(U9khI5;PrNtKi-#Ϟ_-_d# b6#{y%u^ gЩ~bmKw%\Ȯ)0{Ϙ|O4lv^%DCnEUxLç^$}|A;-ca�X'K�0=:as!.< ][(kDFFrgd"Os33JEӣq/+ƍhY3a(câ8�HoV{cwMb4n,XcޢKh{/PD ҹx4 << rs3;;;;e,;6=tT L4.DJ.(z"vkmCSqFR.Y`|.Y)K;G!P<WoԳ$eKyQHǕ P(l)}뤧4n}7SJs}/l'=!K۝5뫪Bl%TQuSQE~GSz@F:"8χת,_ O0*^2\e?> D O2C1<t?ƥRE GIl.1gRY˜߽ ̩gJWeHN,Z0߲MO>1Ie\Hbw2xTl2u-VgI\?/Y2BZ[\*qʟ3 ԏfTo֒?q5.[ PfՌ$lϾ"+D4l݊[ʪަ=S/q eCM']#UrxH :w9z: yIqt"IU?OdݱK\y۷00,h>vPgQl2ar.)A.ơ<ՐZi6X~5ӶyYQcDɓ p۵ :~Y`62;tks^`V5e72 9b`G -eK"0l]�MNpV|| BdOҥ+{Vp6O6/KL(ӱ=QUjl[ 4m69o~\_#7:l ûgGʠ~NJd"_=0$Is~-97Ϟ:**W~ȣc ʹ@u EAvl߽^HގJΑI3}X[ m"7ӾuR5Ond)#Yנ{'$CouȯyuNrPvȞc|62szE[;vȟײJY;&s?waigq|BfRpJ> p`FcDF LuHo#Ս~F#%t!G?豇اc*lzp)%-1A,%m\-#ywaŕhdeeL(@c[BK(0Xft6o+9fJF^x+9Ek(M(U9FӃdHsvWolu 4GY'vpr|hP87ehIʕlpӵʂr~HUŚvj}(]/ٷ 'j3U`9ZM_CkBᷓ1? eL]םbFsHQ9J֐!g3dES|mt>lPf+WIsXx?.ɋKvriOHFԙBG,\Iߐk8;)'R.Zz| UgqLS_$3&   v<# Y'%u1~Z͍?-gpۅJyi:e2&yj)JܻcvZwG޳=;QejN\"%s�|H,Mel,M)|9|&Nwa<;{gƅUXk cܖ3|*#V6Тt!If琶 n:\Gft; 6M˦QXQ,ԱJ7"oaٱTZDsrW<p?vPP/g%c1)JƠ̇W k4^L,B>:Q}.ovڻ<>0hNJ¶"CNSH:\ �<!Ga Zi9/^PPx~ 94uEőѸ7]@՝ԥu*BCۜٿOܹ1lEO6cT<6` 91@r2wCi9/7#SpnmO&qw <l3wI,hh'ǵZ{,LͰ+T#{<L?'ts.̬PLݶ% 꿷Uot[vZTNeDZu (gB@cZTscLs9^%m('9)ޱe#ZRKS3l*rRFl^g [cjB1Y*lFp{ue],ޖe `i5΅SI7&o>މ5gSo(6p1.~@K[7V_fp8ߚE;hAH¾8^. R2lS.:0u{{9z47(CA{ LL,p(TF?N`-f}^AG`\Z5rkxKʝ+{_@׶; h}ڎf HvToqBZ}]տ1eɡËZhnNA1i9?oAHI# ĉ>:ҭҸscA޳2E\Ё�/Os-?ZM H~{-#<K,l2|F¯/ㇶ9LuF55F?i_Q� V89gso w'_ $&F Bv$GFFTc' (AF)EjyQ ˲=;(ٍU{{+LiQAaDeX4n2ǜ^?ժv_ޥSFA!;rS3C>5h'jX x呝V!mF´Mi`Pnc/hr:I1qBsvR&4%/p}(P3B/zA ,cFA%cwD} J*U<V=,t0YSh_ J;(rS7kdLy#gpÏgnƖ,O}My�� �IDAT(f-AЙGA!2ɢYi7h~OcC*Y6 b)AȦ4ls`х8dÅD(Q#%u4tJA AAȶ)v>>[j:ţg 3ŕ|eצ+[^+<F)]i3/:$ +   d6['_R؉2 1#   ` b1AAAA3    ":fAAAA Dt    .[L"jm'J 8BG?gLqG?o:@o5{4΢t  gDAAAA01# X A< |CiAA%2 5Lkidfy4&C0cN:I1L^~w ӦfnD#K(-)U-}WnL_AA1^8|jajBNbcBmt $s *ejtd>t ɨk.= VAt  iʤ)^nnzP7|7z%'}|yOBoFlV:a֡ώڏÇ3OL><Cc4"0<\sLJ8ڹd)OkFrXB4a3Ph\xRr ~7^fAr;Faŭ0wW8H4,;,L:ᚥ^ċEJrlZoڈE^ċf<4,m+v~RlQ jC`Yy>}! }L u̸$Q]}ٳi;?Zň6nVWȡ_ H}+1s#)z3T5(b']:GY=?u;kݿ=ֻgٱ~7.O'hne 4%5ƮaqBq^ 4;|G pג  {PڃrSh:I=U1hv%%.e/odcƦ3> )rYa�45#,s~VȀXB4yb }!ςm9q|''O62XZסkBH*&HrO_v낐)pn7Ӿ=&41tAAbi1y^ke^/YhUo'_b߃i<HB.auxcW%\뽄jd5jaH2j5(Y;A'y灿ܓK< GF}<d 9ONst):99EFci^ٖq,UZh<=\ c>>1< AZ] 퐎<'*o5`nD B&QZP*,8>AAHnun\FB2N_&<03)։E!q$/tu }īSNN6­|6L4tLؽ=+=E!b`kec,=˘WE<Jм;ք_-HƔ()VΠXR&uIs̕T'?h)/W IR[&^#qT3   Z:f̿ƍǷ3)1ëJdxOX] 8Fe8 6Җ5d?jq%#3gq%֎hΗu2X@Lw0.WPGxd6SKQmY$yune2iy{ՠp@bɻ98y3Qݻ}   d+io|s+ H$KXϽ>9~Cq,m~ϩ-qTl`ٮ 4U_\{~[d/1|tMy ?y`؋DŽsT*Zՠu L<sxh%N)QuR#;uSt-_kx{3wNO~ՋzSu"6ƶ}8 f9՚q:^^[wo9a?4&Ө`*=yr`њ]Nj0#l Zػ_GQZ^butIÙhe_ٝ|)FP ^礇dҬݛ4\u/м}ûuQv>?֝hܯoQ4@*'4`Ӛ$W 0sAJ,qS~ ֵ\[O0 *u(ѥ<sީzEO?!,Bz=:G +ǩuy,/:c(bS~swu&z\NoE~".S%mOwğ<ŷC^3ź'|6(>z+7nw>i8mG s//69ejPN(c+=G)=ׄX&r[_?;zˤ& OIlnST\\ڹl ێ9iA͝5d k7\&h[X6`l1\,&) c~u-KW3\hcyPVcE,"GqByXۚdE2K_A8Ț@Nz%(>i ʂr~LZU38*jxk;#d_7.X!S4z&A-K1mIlҐ 9)g_dܿDn=|Mq˥uqz N CNx!|S; *%1lFYȕt&N`nNL\[H›w6z~Y-ɊZ]ZRf {#CɊ:'2L~$jUイTH\ݗ> V-80>E%\ck%3;KٟSo+r'۶cҸC des]w-[(6~!N}"M0~G#q.D%_Tr{x.y?CְU? w&~ 77 ZN,d%~WZ%2QiI$[B8f˦fI(HR%uOҺr$#٤4tA;rG=G+?Ϊ5,> ٟѥ])7*(.7qZfȟrWg (mu"ic9AsLu6YJHxtU AnYSr0]&ZV`S?R'yxY'|P=DcNTf,뮼Ac^?-M>m2 rb7gD&wUiﹳv5%M$:+rq|VLŀ%97GCzQ֌p1:?F.IM99Css1JF֎~-R9(:2,s2He2#iysd"?N@(-Q6y`g093E]~ŝC8uMUL�&rwN^H= vsNS&& vs\dش77F T> !Y:`iD\(m]Zy]g>t$~͓9C8|MX~3vLsE# ",:Q"^})[c-Fj)58y|'k&w"MıĈq6n'(I51$=;Tq4Z&1Yl"Lȱ5sw/H vϫD1Յ*:{e@ R5usN3mr#ex!#E~΄zI9 q2/ oO3$l ?+q9jvw[WnMF_hdd:,99ϮML޲nxs'2ne$En<[cҸ)xԼzyZ|ݼ4A8,/c,pPʕz4Op7$..eҲ:nv18/=$\#CS|\L/Rd<oҭ4oe@mhQ9.} O eWhg2f*N>?¾ز=# z\Av .Ct[^3UR*ʟ=#k뙹{#C:'2'CFq|dnO~ܩT+ɯ+~4X_Ѻ߮l nG`7kPO4u}5Nwɍ@m9C)7jE-C+8Їw12<꼝|Dsm 'JOq˧&{ ǗJ\eVu 6e(?r l'vDKTS!岡@:&ί{4Fb"@Z.OMOL(0b_ gɹ%M6¯i7.>SCAxRjl_ݠ.ozSUlBx}v;u YM:N9R@^"xHM{O:|R>YMMqf&$o\{UV%G4Õ뙾5zH&LlP6CcGLHJ+ Q8 :kuSe/hڕUQm)VH[1~XՅ.Juc L4IR]gYVoE^1W=͐c85FbBe t2#  C} o7y3ufGR[VC :%8 o {HKj~{ (pٝs ~G0mV??/?Τ,HQم 0lVqN}X'HVeק1VxIq)v'Wo&Ӥ uKAq*s22Q7o$ <u FlaHD'$(l(a";va _xGxꧺJWZ=/NA\1rȰvyRLsJ{N~ n;BbwޑׁՅ#C:'Tnixgѵ|X&3ߨ,5/4(0i Brxxz6:tmll L)m) 5M\dSKQ<n '2ت»hdKGy=%@M2-MՅӨ7ǹdi[t k'PQl|4dbZ-9_ި+RL&/few*b%m?_'$)I_l-dG(|Ѐ"oAl, `_bG F %mЮY9w{[l\Kln4Ȩn\eLr!(>b�c]NߥK̫$6٠7;M1.#%<xdz,ߛ!MVu;|=NpNoS2H芳HZ wE/fTo$akTv>we]%~Ev[^#iʀvC 2Y%Rv u_AIUn[©6nK4.֐ѰMI ĜУVtMkj|5ӥnYJH~sؽE! flJãDHJ'~ݐze3)*➰t�Dip›@[Y9k ޢFBGURHL\HA6DWhԋ)v}z$+*U8w:2C924k37Sμ53^dCΫO+ e~"0n#!b:!lu]x־pؗ]pfzOW-,ģV(ZO.M 4s*?nk?C=L!bIHCU(0B1FU+ ~*Fq2p"^<H><EJyHg~u9GRފ(w|h?O|4(~+4ɂKfQIڔ� Kbצ LŒ1� 021FJJ{M_́"eݱOJ04CBF %,ժQ݅?t[`V;mMMWG6O~A>?1t oeL 7gYt(;]"}FxAPÈN$~]kC>AHX!Fk{Xs%COu51vCZ'w((G9Ȱ(llL֖\ S~>"%%l& 8Io2Y~j6(uy y0.څZP'v@ b9uuIi~5MOh~5l%œ3cZ93iB QRNt|} !"DsC$OrGՏ|]lȕMQ+#wc~OT$@tM_ IҬpGQ]@_n; d�ȩt) s)j#Z#^P4jjS,?yȃ(Ul'ަ^qy&6͑Ky(u.jejFx mꅧCjH68VW3ΎۇвL7.i%.wocLQXWfٔRCu?U a@Sf[Og9=ai'FۧILNH!B=7Ѻճxϟĸy*dI&}sh2_))ߥ3>xh}2)cp9Ή2461t3~aȊor89Ѩ|^eB2T&FZ5A!;]a74+g=i;6y_vpnXeKy9 GRc aqIFZN|}Zm֘ʨ_ua&|ۏ$_;-O3{i8xG1qWC4sH>ϐJ |5 t( /i4?VW&*2*cѕ?%OhWMҞNaYoA^/Ϯdp볘18=n3ͭhU^-uWN8J3,LcaY z]ʤ.i^(_sf/qY7w2j@5xмё+;ӘLu $ Bj~,ޓo((v6q9giGOS,8}73# M;'Tf1}W36OFQz(_ڤD`J&À7_ u#Λc~e\0ФX~$o䔆k4Z?KXmD]iUō>V{I,aP#&08feqoze:qdu܃RTįAʳZ]Ist,>|dZMs>TUzCBʪC0VJ3>(v\p!uY/S>mY{]S[]\ `a r[_? z8GJ<s]4 9M I>}9tt8;wrb~c.Ͳ [] 'sbx 'j[~屖иXsx$Ga.P4Ync |x!`L߉AVcu ƫ>vHNM`:WgE#4VHa5;UGzx)c0di޼!Tu ( &jM ܩ;-)P0}؅j=g=;e@mQVk42(llmǪʍb`_OLNst- kQU<8^r4SF}EucܶO1 -4:�� �IDATBQ]͘0W4>R$|50g{GeFIi$,I5/!Yk}^;^\{ar}iǷ. s% .Dx9Rаq׫l''IȋQzv.k;6%/u265A!]\(CI_~uc+<U¹)};&�P:mq /%`”H..ɞ 1ԱS~A>Si<cצS{1F-2%lrMࡓYv9љUT9]J!Fg 3#u;~kG;%&+Q)>nMkPԏpO{`9Ǿwb0v1TZ$;l4=x븯Q?%|/aհ),Zk),;)(c]nS"aS%h^r&-PΝA FE=)U QP).'&g.Eit]GMꜴӱLP_ŗ+.T|MԄ]\ڸ=XU!ND8(aGrDž~YZ meGuMQ/ f$pV<s*۴nzx-Ǡzb,sa}rLXC?HrO1ϸDBD1.*436Q]MysheK= 4"prIr?O'~dn Q+qZELTT\֤8P$ ·s:cJF+%W{V5 u2+ bD\dɘM]jw+~m)VcxLVtרD]T /Q42o}2xdEFȲ]k6?#McY;D?Ă\ϫm%OkD%L{Q\aHfGΫ'>yG78,o򓯀]sQ4H:B {2.SJPb;x7,R^D[MJvUɉE9RlzG^GtѱLkg$ *Grׇzj;J#E)0ׯ}|�3ѭgyy&7MaQ˨org. %ja_F]Tʫ3w0;MXRY"yl }8֭!9.۾ܚ1^|֦>3 rŕթEs?Oo}؇9RQQ yQkjkKץTX;y5]bĥٳ1{JQr *wEwAe$bxx+%Nb&aL:L+[K璉l2W9Lz9V_AAޝj^,JymBya;u He0Q~cE)^/9ci!QP![^]"+=)SUj}LLϹ&y(ߤlu u~ĭw2^Q`[+ZOMuȵWo/:/wi?J;Fg#3Zch{Mi۽>}|{!Z3zx;*9Gr/&Acm+:2lQ礗eR4kqjDFi+W"c&r82](4yQ.PxvzF}hԚN(ՙ_`ϾWu^؛<YFn#ۡg4!vr4|߆zZք<`mohsf o>s6hėI eɺ͌gx0%Wqׂ} <%Vp>IfU;[GgM[=⧾ͨf{>5W"ȍN?wNed].]ٳ: wxV,~YdBLYDOEze:TڅwM|~N(_^wc; Ľc7̾Η{YCUW/M#AkbYc &Uwgw)t҅|J"7Cū _yݖy=<O+qLY:9ǐr٥ ߭4qx.Tv3_XmJQʼ\td%L קQfwǼ>g{%ejN$b^ߎg_6+ı8XO?qPEtKsr{?V èP I<Oa|Հ)yC%SsLSKm]jֆ7|ri4‡zC}\jؑr,ph7=<wV*4ׯrM*u]^M*dr#u N_H%f)>i&K10?usPP8%RmdM?IOH*P2s f TvϤ z-7 ?2;,o;P9,݉ɥ ;4q^3a klzuFOǶ]ؖ762_l+H߱Z2fVr׏GXc 4 X ɾ>ز{k|v"};2mcAKTH"3:!Tڌg [cjB1]ꍉ*zN_"fָB3wd){YOCiץ6=]0Fadck5/h\!QM aY%HʅmKWwJ4NC,Sryf^A%ogNE*ѬTv[ǀrGE;8Պcaj]㑖^GꜬku)W 9էM{TµgpEL^g?/W/sk4(q(R8aXCMSY,QXb^giUo$ͶEq/N)]6(0u*Dކ]d+&Ky}/f;'Hjmc G[i֡HʦUnݤnR+k3&X({!4g9e~J]Kn)&Nhٟo,ZL�sp%_Eeb?Փ� Nͱ Ѥ"VaǓm0wA.'Fr2wCi9/7^v=W;V iYƘrĽ7KP(=^TZcd"/_FҾ=̯ $9FANp(�,9lIťh0,؃&o32{5ro^KD2.B]P04Q14ϸة ǎQx m=qN=AAOV/A/ #> 0s(@~[-axH&i'c2u ɾ2W+hlp6F~7A1/M sYtYL~~{ ;P6SFAL$:fAQ'_㎰{Z}ugcNJ[9ueFHϠPL-4?|öq=1 !n{AC2I D鉫eP2A!seXAA C7Ә+/֖A!Ӊ3 d xRe5 B'*a'_ 3[k1IAL'veAAAA01IAAA@Dnj    AAAA3    ":fAAAA Dl- ĊSq[f7#zAA!{n!G>: OW\;&0;n+EoA pft%\qiǐFScfٹc_{ro",X/5.KGxl͉k@x;ݣgp~hȉD;yı!3 B,"Q A�Pqk|]xΛ;/7tA AELeAnt[CHԗެ?|9j[#IJ?= EBɧ>|N׉ c({4a* %lNW_XD!2H61tA AEt xXq &F aP…>%aČ')\(d[igK؜s˯~(p~ s9; +SCIDA[Dnj c @i4tbҋvcg Q6Aƌ K28nɧG  ;1KlܟVukPƣ(.)\+|@a߻QX\]Qtc[R_ŕɵpsŹ1G+R(o<vdԺpƽ=ھe5'%* s'3@ī٫y{y=;ԣdA7H聆v([^ &C?΍+zzh^oN+ypwO;ԯqխGA7\\Q -~X9"܏дRi hf/#3!z=YU6t cނ }#U|q-?WBu@cYy.uf%^=#F,!;X~\rXQ?k^_eӔYpOi4[;ڥElur{Y_ SR~eN'H ˙ٳ]ajC$dϰ\upA.ebՌj7^NyEhE>I~Ξ:suxaݳXӧEt ch:h)7)vͥimrBtuWf۫z bUA~w8o-ZOرC}Ugptq\@C ƛH&ĮOcbj,F̤H&8[W07'lcQ|u"eRz#^Bv{ϑ7WҭXd>{$}Y^ h#fRsy'FEq ؼg?'eǚ nXs=חϊGIz<\9~ jaޖ}źiݨb<3bgoDgDWϢ8}KZ"iq|^̓` 񝬙܉v /}m'W G 36m,QwL1<:ě(&ןsNVOjG).hM#^YSgRMLɺ1s[q:1יs,br٬۵v察i).fTeXE;m3}ߡL)@ܾǶdx}r̄zx;MOY粑BvcTt̤$2^Koa/}v淎U@?ۇfs}^б0ĵ ;X%\X3- X? iSѲ)ZbY̛:W;elMpO0nsx;vgu.,G'OreG[֡<KY3Gnedj-KN!E+- E-$}ܢlk$J|ʲl(l2[ ߎמy$G)::^ rm\&j=:_mmd#!Za+[Ʌme[{Oy7z;κībWOT}v\#.MO|?bk+ۻDKgᆪ0 R �+H节T: RD,X. EDDJAzГ|$B4%s{Νwܩbr3_f1kZo:̸fP388Ԍzry𚶺h,l|lpzw3ldտ%f afppW79ujϴuߕ}؜|eΙU4|So.l{Ӽ'" kMߞ Ǿ'o?O#_yY., -a>1eǵljy9qҺakݔ6U΃jɵ}ݞcČ3:SLWc[]LӨw)giQPTc}#+- ʮD^Vnfu{JQ)~zNc>]mE\ ɕr}>@=X^n$Vu0UcgHhբ]rʥ[OٓrzOJ5.5G Q? 3^.w^Zg.亮:j|_lrj? U†K3WkʦME,:דF& tOuWwuKa:mjW,+}Ov-:lᏨCԷ*jfyɛ0V3|Ki4bbb*[sܾ�R#qçhI1ur63ufvqJjCM[^g>7F@UխoMQnXUte9T//JܱM;n HUs,Q) th>/2IXZ|w#[J4f&C u][fNޫ3.ZE5ܴ_yU5oyn!>zWȥWs.LW7ncYW@t&UҖ#r;-s)(G;&8Xny0l I2>I S#iYo9Sv�a+Pm ~hcU[PWV"U@AE-B9u.ŝ>+$[ K㛅oj_Þ9BSh{M2 wFq.ov}'H6]y.N\R2Ξb{Cv'D6Cr9~{xRZg/В {tivEWhsqm'QHh.ٔzr,F& Bn748{FgoxJwqȲrDgTN[b-]T)UP}pI /7kiy0l 1]y=oJ]1Z~PTRO])Cu뱗ꜩ};�\`[>6TktԝutݷhݷW^6#TF 6[w&&.?%9rasZWpu:Pϓu'o#v/YNC|=hgO7ȰۮEyt<F?-ggI{Fmeg)dn!ohUO,+}*9[&z M:IÞᅠ#l_f){jR+M':-oә856K-pP9eʦ¯ S 54 6҇K衱c4eZm?pLNUӔTuSq?Yrwǒ7~xh[sƶObK>a)=^+\~$6hSX]S�Y[x^bڎg_j#{)==氤 T(&?\r$m\ :[:kJ@|< %԰P5s:R}1]ib{sgB(v&5N2qؿ)C?/ س igO}d:?۳A&z^-X)ڇ:tٓFmtw~X},&ٓeM{rIg?j9jaE''v#pey3D(!')Cp:察`Vdy^Xŋؤ} :y<VGS>Ԧ>MYEJ d W"׶wꜹ};�8ͩ_Oe?Z:.\Y\Ir<i|aq]7E})($(t<],=Y&vD}{JbOeJ Jk ? LHPB}ڰ~Bc�� �IDATN1]t|&tJFD+Ir*Y/cSmu2r˽Vs<UQp DŞϳ+VN[¥S'\+gWO+Oq{"ۏWnyCf@ÖkρkK6oݢ}6h&2ybɥi2Tԣ5M>cST%GTLzt/iJT:<[L5F&SǏj?n(w(E%Wenl7wJ7LP(Kkl\\=;vy7d=bwSv9dȧdiH[If?rm'U&eJrTMZ{a(xQIݷcmryl^ )q֮?u6wpƟ]Z˘n9$ًw$II5}Od^q=Vz +(X7=<?2]ڴL[UOBP`d<hWbSG_PNڴt;MrGTޔ/^s�p=7;㽕UR3⬛/irP|rw;pS_ {Ǟ`9"գK{b=ajڙم x-~^@G]Y'?5iJ=~T#8+WkY2tG!96k֜]Ub.A)#Jv-T@ ɹw~x9n|[5lR6}>|>ґ٣M~vd浳u2RCua^/uԁԝvj%-;A/]ϗ6G ʔjPgFV8=5\GUz|GݼPjt8{^Hܣ_}[Sm[i۶s3<p{m(]Rm.^I%*D)Gdy -?A`:gr�1B%F T5/ڨcFiҦzUm\9{mu}f[ 6{ջSݙ7QKFٮv@ڽfY3QӽzcQ4Xi|i.K>Eն{i]xΞ^[]_Vjvvly{t6MK>E[gBiFP==x>}?n~ok:?~ ?D+Ut QWr~gjW۫~pcwhף5rV8uR0ѻutN5Jء>V x g9req?Yylձ9ҩ˦<$SOdv /<lЙC{eysGwͰ)gxTe{lEԢ3_V&Z~Y-wG,+(@G\ Z5;~=j~CjeSFj&*<l'uǾw>o\SZ"m<f-]GU禰j8oUQX:4ȵstb8x\ CTcj߫|]΃>Q#i9sY"B i\6u7꜡�`Ɲl;~v~S nr9S`Wd4/[8J]J![p^?HE]ڍmmĭ:rzOK}/'zv]?GT;wHuZ02yjuommN}}]Awҷ=)wBYyF&zLBjHrE_~lT f{~R#7#qrmszDt妔ylٱ)^Mh[OnL Kl聒j6y>ūPN}! _O곓Ȧ?ۮG i<@o sԺIzSMQx^xNu}yezo5zZHuf߰g#3*.}M VnG7.oW1ג)W)myz-IG֘&oBA[X k~%Ud/reZR.O+DN/9#};�x=LkK-tE'lA /VF>,q=_3ݡ" PX;4s u:O˾zKF+2ONeSuOnEֹ<jgOk>j[ ?򖨪ǺҜeSխ?1<?ԈUpneϮjk|*eV}UZ0T-ϑGՋ#giѸ*v+j䪦ӄ>MT=2L\SSl7ki,ˎd{A5\MjU6V`370w5w@=Ws)[P~UxY vƵ*_<2_yodcou]G? BxOo<VG3ĥCUg䲅Kc?u}lj]rЬõ&X|[}T I2*aU{AU(sq- 4/gչ��RfSt^<UOW�ns8_ GiY@f֠Z7�#f���])CF[%uw�n 3���@jF.*_vtjT=iS5A2|T.a����u|UMU8P1[ԥP\s&7I]L>7O�@����n}M-]Ӿi j C��A0���->|T}O=tT'/(wD>C7oMP(w1�22�����Xa�����,B0�����`������������X`�����"3������!:o| 毀JT x'Y@eEFݭV#ct;NB)D d K{tX>k/Nd*����uX]%Q~?DI?EU9p-#ڱ}9C9HJD(����n`l oV׽15zcyb'0Gl$S22&L1u61 CCO]y����G0ssFؕ-g`d]l*uj~<]ۼ:^$!#~bR*W.;yBlׯiz1Ѱ餻r���� %0#Z1R- IfVy^_'jOkԸԪruHHH*ŷi#jvJ6y^K*���࿈ �3󴖿JmU.Y鶴P:`}hEwѐg����`F 9|aU.UBPTjg !Y/_jq\ lZ߯E*h{}vt6 WyYKr~)_/,RGmsұW9OnA/e*RTJ**;/r3¡]us;gh[)"c| sRr;4~ySj ����neJJ?߮)vOޠ7j[3wֹ-yD߿Z'_[̿~՜i厀4Tj}\y݇|~;]=+e[2>WfÎAHNZvׂi_鉏ƅ8*7cw}oi|rJC'o+f L}8u_nMūFDncJjBD����ns\Ԟ^ T.hew:9Վyz?OsOxsBbV)&fbb{l=%ub._Eѳj効b`Uy|z]-;FqI:\NKո>!W\uD)WQSkr}{%A5oC7k)]C#;;d*fFjw?jŲ1U5/Kſ4ǕPР7S!<_=uZuTv؟rLjVf4~[>wm$5l9����nQq*}2r\6>8PsQ*[4b jػ^*:OtJZ.&6QحLh[5>2JLӰtwקr8fzK5s&[bU\[hŠUh*$19^g#g`{y9dWgiPٖ/+{єj]rdV&:4[?*AI%:6VϑUt}~}U!vOVw(DyơtՀ~TS*㊿+?O^R,3 ����2#fplZGOJ(7'J˂+QW!/PȨ+u-g<v%[`zalfb~ZSձjLylWV/E!:K*`[V^Zg.\t!#"ڹdUұ~ZUSE;D1{v~Tzܣ_;tv,|ƔԞySWz?eߋe]0P-nR&w*4����\ԙu)قrnWt5mzm [ފ)VE#ݍɡ*Gwlw�#U-:sWTJdȥC7^#[J4Fzd&C u]`UЦeW\med$ U|uSSm!CTܥ?蓱UpVTčZ ���vLq)Y$UXoj?Su#)h{M}e+܍* Uh tA8sI9-M<}hɆ=:r;y*L#rמW _UBUSƷR7\ %UyZ X#֤e =__>jZoLe ��� ̸LL߰eEs j]u 5NXM>i.v+sSY-/:.ր^PbUd׹R)(wRʝr90 RVra:+W`r:z< ��� ]۾>9/Rl{Upr8:N=(G>RFZ`ng3r>hNLү&F>tA����no\aOX4NnIOyioU` _j¯eڂu9Ү}Cry]k SP=bH_{Qڞa^P CrN_ f+����5.߯cSPp.$9N4q]g F| Ҧ:8NYg}d3$3!A KaSSzK7lAdH]HHS^LbQFN.S@􇣕EJ*W54iO,]&iQټU[����%Y)d=rR]r_{8t֍VR馟i哮vuzmv@S'wM d6L8Gmz~mEhI-o 2/S-GPߤU.ȻkU>*WBO,т $#wm=\_R*Y"* WN.iu6_MS����VB0uu_]Jܩ/T3:L)Y}+}v)q A%ߏMF|TجYsvj<tI1sӔVJk0S+GjeTPe=[J +ofHνTqC>S+zJj5k:eJ2'TdxAa}b x[Uu$Is5n)oTw]?����n+Y RȂV+҉}XAt~Z<_Od@=ەMuאCg{Ťq sG߳O'.jkZ tf�5j&,m[zSDm^mț0P''۩E{i(S'Z0-qF.Ѻ}X/�\`ӐتQOwРWh㶿iz S*Wص ְ_ɔP>yZ^i7si\jISjkGUGyF϶\z����pal>Z} GQ)^7d 鱼.;54o':=-϶kb#?+lxZ90|Uw8}_x✪j&ܥo_{R^yͦ{j e_;k#>Kڿ\ \-36}gjmݷPC:-Ԑ+/c'l$FME|KxTv맚?RvV!Usjc[\:{p8C#hȷ虒^9#����bϯF#kCwHhVN=�\:CjIG"T<--uh<9M=ͻiZ U0<?ԈUpneϮjk|*eOcEJ\)>rʑW2>ׂI=Ԥj1e<^9K5W2 Īr%Q~lui?xKaZڧvՠuw [;|OPꭖ_���� 0Ynܦ3kQKtЕ[-ܠyjOVq z[Z;f^;'ڻN])gX-ljGyZekF ����Z1,]AjP)~wڝ\g%Ǩ}Mzqm~jf;NWީ*UhR(<O TZbv|q'���1,䬾\KOO?%կU!ìqL?wG}5'JU*T 4uq;]۩S Tj{Wh/����d]cZK Gϛe9fp#V[-Ђ%k~.8zFo`)wPuw ���4 kp7TNU󗾦����.֑>S72 ����6d3ڼ ֪a����f������,3o������,B0�����`&-'$$_׍=Ś�����F������X`�����"3������!����� �����Ef������,/ֽQSyB)$$Dͯ2ȱZ}+THH>Ŕ+:�����1�����`������3!⭮����� x#_fV?䲺J�����Bfae<xN.iϟSZzqZ~(����pKB0sY _(6/YqLk&TBdqE+V4{nu}] ˫`*S^ři➥z5WGwFE)_D1-S[P x8Nl7{E*RL Uո M]ӎS猔K omTT>C]kQO$L< SPj;s53�����܈aoHIaC5x]êU77)>|+-TK9RN<!ctSWP D)[Zk\�� �IDATc4~9R-T=Z]im;rN#eڵenm'LL䗣teUOrS]:WUJIg~Ӊ+j7O%_}7X𺱱GW�����<وK5!)ɐo'5՚ׄ22kn}(#IfA}߷Yy>͢\hLC\}ɖks8wn=oHСՓ)3O|^Oid&ԊAi.:{\|5awZl^yИrM_mj霷* ����DƃACc˔d䨤cT)'GoU)w<-[2S<~ɷ$gCg`w..u-Z0Io6**rJlybR`Vab"͟򞞭, U_]w1{,"F4]g} jLGU2[rΣ=fy/Wѽhm*ZE[}gIJ11h~RU}T/9G kwuݷ�����# :%o=.aJ_q=R) Ղڝ<0R_y.+QEЈ?0M]^PKO=yR+#I0=:3 l&CR; ҃ 4wL9udjrVW$t+ >l'eʩZ͚zO?.״]~Vj/+LM IE&/sg*qO=%ThnIW=+?JrU._E&f4zݱayLoюKٿ�����܆8-hJ=\| # m[%{b7KyQ?+Q2D)udR㇒Ii=RF%%ۥr(_v〜<WUt fjp#TWt\铱(#(lڑqdP {M)ȩ5RVhͮu:mL�����\'s ps+NgsvK :43_ؕ`J 9s:{Y$TAcVΛb&U Tb-=TuiV^ixOk_r.2WgQ^+^4fp\�;e�����[neJD.RTu.d3 mFL=r&!$['4jLH9K kǪYzifnb״K6Ϗo<9uZrO4`dr(j cV֫T'W* ����B&5Jɷ;z!ɩ߼gY]}f^z<ZCu` 3^{*e:pV.v-g҃iߡMO~^?:l�����=bYE=Wiաz|9;4~=9S.mJcȿ?k}͛A<הRޜRoJrZ:sF|v_6%LV۞}Ye풔?NʳWRTyo *ȧ�L{Ze~*7e_IX7]C *.^z:_D����pIw b[Uޕ[]Z|ԅ5hOQڂl!Lr;+{*T~\pLV<e;9$%Y\di=Jz!W'TJRܙ3irQ*Tsq箮ؠ}gRTnu &Ãr]V禽DFj]z f����Pner&֢<çh3*[!R_'9"ԙG3r!{y~}5_'y[BBsj|Ñ)1rTܧ~k^*^Hŋ$oS'֭Ӯ}i}1VVʵoϔaȖ+JL:C�����oxxZϷU/iC4ހ+o*=bps&w|Z쩖t*f=]tw͝\<,~Zg2mA~^m[]iosW{ҊʕBTpN+L%,@݆ҋTsa`eVFNU}xSr>Fр$&/XI5*zXnvEw[|f疊vs�����65K4W#q%#O_+Oh)NZ|Tiyb>}>MFFzm*pgy8g% 8mG-&YȦ;۶T+it%quC*צ"-WQ4Kr/CV)1|TMG5 3<.W/:�����[!\U!I.-gSnIz˸aSjKif|Kwn=>H\JW,6z~OqdUIퟬ*?/3EeVVQ5MA=r����@xȦ<*pra^ج?SHX^\E*Xͦz﫟 T~g]yO VLװ *$Ws+xU{b-RSj3ao%(_9Kֽ5駯j{ʵ)CǏJ*'s(ojw~eE|4U ����m0^.BBex#^ �����ˆ������d �����Ef������,B0�����`������T&������0b�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������!����� �����Ef������,B0�����`������������X`�����"3������ٽ{u��'8+??끫`߭; �q+o�nWUWz��nvkkb30=9KH:IlTocЬ;LtyF>xC_nKtog5s{\�%�g.zЧ).t kt|Şܠ=;nп߱Qp_X9YZ~k$^Sw[|ܩ;6VMwIY<4h\}kwԩE#թYG<F]ޙUnO` 10>B~YtRe. 6zWo{;!ؿVVW*&f5=kt/F{rIc=\KUux ]b+N_Y(Y> C~w,'t J fLΝL˦oK_j>'u!!AO9{^ppϥóiSVF l٭eSh͗4]5F]NY4$+4p9A)38ˀ]>؜KvGU?/2XSSq($+_:v@3< }URCU뽧^jV*U7@IK1ɴQghqgC:�{o^ET cAh,QcI-DMbآƖb7DD?�eݝ}Νywfsy?QB f-Ή'ܲ!yӯ7Xܣ"Kn!~>}kA5ph"M4yjf/"k2dTҢ:>eL>_?1iҔF-_~=H 2t\vєqe4Wyd_;뀬ȓ2E[(w0{M2Θd !~wt_'^1GG6hBV苅K5n/A.cM xQ8z;A÷yo46\1)HFyIԆ Zж8f $R KI"O<{b0nj?ϧhڮ&Tꞕغ=a4) -_�vJ*Sӷ2nRXj*T r�GO`&xM(Z%$Ըf ybZ*-8*Qpp}s&0u5"'⧴n65Sն t&qqH�^$-Xj)+x 2թU+:>Wx? AZS\$TQW$cS2M^ͫkr `ɖz>iXǺMAvl<E}굋#wz^5 }3>F1K9̞UL<My scӏ#(Pξ o2 w6|g(s"wmcŌV'^KE1*T IN#D8fFQ!?&UHPmܦ;M{#t$ʔmؚOrx]e1*7iCU8T -_P'ta6Ԯjg#jbOmo!1qnU=s!bCH/1z NɃgy'*x诳s&~Wn| ڒ-3~4ײ|~tfƂ4-fj ~3|IVƻ'?Z}4%x;%F ūQ-$@t_ĦkQhҥ-= 5Es}dO$䢭z'ap/1hܸ %"|32։cjT,fhu#iTT j4M`쓬7mV|M4Fe7Mvn>OIE.[ wS>^vͻh߯qRf$X{يt/%{6ȁ;Ӫe+­{9ߴ>M}I?>ߺ(1zd:f\i3lm)Dc_;U[~dx8格18 3%( y Х؀Pmy+-Vu!y{uslbܒO{of߬˜<utJpwLjӫy,˪0}`8dۏvᇃiR,9BExoi~|SeSn'q!A¹IS*4QOʼنl=l`sCə| ٣giܨD602N|ϖwTxms \8EE<zhS@½g|t.DG/S~MASML@#?[?L@V1掼u=MC[.9ԧ>;A r4-t|YԒrdd~E(h뵦mm4/?eͲy�,̞2V%j뻩 cö1 Bl bh+T.:]g@TPB8o,~䳍> wbkOРWBLATVEfA^"SB9 Iy =Q t[78BXzf @؂) 6t+2XbbUxcF}Ӥy4ЌVTG.\ I!5k 'V&^5 d`X_^VyJЃXmeTH7[PR  z;fR06< [x]&%] xN]"_`d;'Ĕ2]rO"bl%;9E5h - hMpB|ˊ(kӨ;w�13qybJaܴ1dzoW3~ ]7+ aφIx?Yo?EoD:-xGxmX't;eQlFҡE2v;ϝEJvFߞW`p�Kg3wuI * `qYGҏJj^|Z*m?wo<^C}-.͐eRz\֣ xdSn^tU$;urH&F}g=OPpq;+Ԡi|yΕ�5!`nbbP"'6\@(:Kg*7lG=i*ϲyÀ>oKgg[$[*V. 'R,n7т:Ӫhj7'둋T˯x$F4>/ˌM~c<9߼Ui}*5֐yoFߛa,y).<L=Kz}+\ݾ7 pۆ>>EO<䶟5MV,eIc $& &&QT&,ro5ptNmɾ2t~*<Sm1D?a jDMz1;lj[Gud;6,Rr6gCC훪bm9_uz{SAg?{Yn<Ťp \+ףnETWϧE,|Pj-;j(Uθ|v_`MdeH:rؘ5 ?uf:`I?$оNTז>zѴtC+>O]6o8uJT{akW,<|_|ҧdU)fYnE[Rֳ|�� �IDAT=Fv͡INe(e!q/7RpyTʅŔЈq{OV,9I}1l>;fX(Q*;ЬKXb j6HX6-Ŗ&mun=X52:Z3IaWc)^\ܳKNr}<FyڦXfhHA\:ǎrE|! G 9!|4xUu3>&>q?2yw &!~lU8Bi|8 z ɮ2rA~K >߳nOqZޏׄE9jif GdO;<nǭG8t|ު6\@jFՊe&U#938W;f!ݾ.풩t* +ՠnq>0 3'w*Oqu.(R&()\_fסP(6:e?oXCQs.N©I3tҐ"Ã3lYt!KRI;:6 S҅{"2cHI/眛Ъm8ĒςѮXGnBx:kn{E,Cw:ygӷBW5eުA(0/Y@.?hB·9T+Lna`jd~^,#LM.hffD^ eA?C8.-ǖk >#`9#kBs#ưR4UʖwFzKpInY˜:_qi뷦LӰr]J>9ֿ17CHݾ&WI�[gh&˧e`TB0D{ǤPI\*$!gqNa/3sd۶kkys&oF!ۿá(S z~&PZ4a}l:1=Y:Qk^1 <wrf,t4Eׁcyo|vXK$aaٛ" <GdR�*wM鷮4 NC˿(<:9GW%dԩ_ {g9~5?;M֏YRdѐ֥ݠ:Oq'؄;VHw4HA2uIgO5IW+$@떲/XAEB.KfU0?a;8f ~_F<$hmzo6V'Fy̾|@ca z}ڋN.λHXxK";sA94g |OP\̙ZGnקU#97|dxиVỊn}bm>:g[&ƏW0m(މn:57H(ւӱ9(OY9cWc=4%@vy'VǩW,='F # nKJ")#T̫JxO4D9Q~{>ғΙ,Jz* 'ęoj%N*J湕T6K&s)'_G1oB-耬ZPT?"6nͪOb%%3տ:nf $īeM4yH}X:{-t9*|8k勖8RҼ|p2S,֕_vf3xS+%1dlԧG_G"I6٤(_SbunE*鑭S>eh0`H(OU#u9O?Ȇ;L{,1j<SRRb 2aӎa?toڝs.߫+'v1 1d8C~fJ}yLG~3Z8ɕ0jJڇ,eXss~$m:?kHR+mL9vqWQBKMxFͳYj'RV ӌ?g>Ern̈́ni3H|)#nG"9h$;HCk;.Or ȍUxۮ\t7)g7 A|AJ>;fL=`t/W9u,[tޮ?vg縼+cԸehZ1BC%Fɴ"/f@SPB7Cr?V]PrhybULRT25 0;ى&GDy̆%-|-ehf`ynsDzXA!g儴z%/|V1A}£`=*jDSL'D^us'+JFqb%,h0&.aJG,0Wq# ua_㨓-ӼU'nh4V o̚gHEQ4H@qm~n+42e7`VtOEͰxSBᡯOŝ7;C9b{_eSX2T& c]*ofY ;3qHEӢZu I[#3s%,dԟXsr>V9a[:$-̐bbxpI]`Bs3MbN݁P;؇yq{α'1�MqJ9k0829IcIT $ Cx L%{!(spT4,/^Dtb$<'*:Uq#chȦ7}Tڏ^kqn;aх# BǤV|zVD.ɋL� \: ˆ,~}(m/~>u{JTӇg\yR[[7eW-;#G&-JdaŃ{܅>оSǨR!jAPҵ#?oleflL`S3`#ޢjX؏N2~d8W'XѠ1/<y0)Nˑ&dt0ڇMe rLW(LĺR՘�+uv@3w S^?&̘1P m*a+?k<+ٲ.�?$ڊh[.y+;Ф}=e@ ߣ$O.mhܑ%k<g[II %h+ Ɇԅ6*h@ ~*d4<[?}KQmdYBry|{6|NdMAXxȶyY~sS7nU>9ZW*c! PCNq)nĢW|һ%Ջ[y"l TOOUg[-c/V.+Q\j$(xJwi԰EJnCT5h==DAB.֜olfӟKYu#>y1KhF/6q7{d]p}Y>eڑdT%!|b%7ze$̜RJK_}4ޭ9# e"~[r>&<U:-'-JVϞrtH{m~=΢g.9AME@M $&$U.I!aS"e=%Pq#Bܽn&𴗈9s31Jsq-{m!mX TlCՙӮL>+9PQBU#Lgek*U)VZV.SE3 ޾eg[U-dgU-K춯eR JB*�HHL@B caO/8_ Pm9<j٥}ujGO%8 $Yb.] mZu瓉JN06Lh4q7 Zl"".! 9P(; BQLT袈L./r$uLٹ1oybIL)ZB^scS!YbvvIXciΧRطq {sN S$ JIzfpaHOC+I,<{(]ga, DzoѪ@vp'0-?<]b0'~W8)Md 56o2v6HjQ9o_7LB q$dA(-D큣S֜˱3Mqp.OQXο3~8dV3JiۼZlc;u:s7T}rf9.s1H#WBFJ|\<$cfV@iR#cQ(/~Q* 5ɍm) #4ekRl| }#*k�T\"@rjXyM7gSY PrMZw5<[be#XY["aDǤ~"OgAo.id<z ӓ חhbbTpH>7URWwuߌd_ueԽTUKllNo2^8wDcYqi=G'gTn{ey]![d.QѠlI; 1#Q[cMӿa$[$ټh%ra>=IWE3n-_aVg71󚣨ϟcINI󄰊A<x<sQ)# ~7::K?A/$҉#gOKƩ75]%LIQ+ Qr| LN.1zL`7=1nFt.,> OMj$ v[£dD' rԩjZ67ˇ#v 3é=0jc EK"ۖL%+Jr@:z25B"#Q% lO:LL^-Kncl2 =lؼ,g+T.%\D=[[Ѫvġ6i#ӣuoJӒ9y k3C?gDg~+33Y}rcoY%}0Q&g:+ʲ/o5>~J߹ܚ?A))戬M7']G}[C ;Hmezol%VdbsAb_ '0{9b3lj6Ht<^c%#3"|_SV=>tԅ8<%Mh0~+s[?_ܢi;I.e$)k~@yYb C7p̛Ş俦E+Q;% Ya{tWd"4{\޾͍3G9|>1j,WW/c{3:SV넶,kf͛\ѹ NMd _.DbH=T<UD2y!acoDdNjs[QOy^~Lhd@:mPy E@bG$ yM7 j5jQNz=O"ȉW9ҕkQvէ5oޔmx/f8HY$>BLxĒ:/m q9%/0x\&o zC&Fr+\EsGֺæܾ k:rݑdh{e^I4g:Ig̣+Y ]cŜh׳NűUy )}ʄh8S,[ϘVo`93 sMt $Lj a n7MkMMLH !!DS# |mZcR}JX*>)m4r(WǩOHߔHX'-bwm}L yL,4Ha/>X^J̉(tW_άI_0np>i^"wO n/Ui= Kװ+@ę1g<ӍdUrdAd- Q56jM^[׸BN&Pڵ1րIDDGu@GsoI @A/B4hQ#"ۜw?^;H𣧨=^h@H)p.c(Ϟ"^|^BþoLbb"$ِH&Ȏ<?a]9u\:E : pm ׌Ij�'8t6CiӢ+N{CP@Tjއ%ǤIoYg[$ V�[xpv$ /$筼Np>go}hXrRC+W알ejt;z$\;uKF!47CpiҚ̓D RfwW ֧"Qj䋹 E#&Tg/eW\"_Sgjip*鄬qZq~\ #[DU'^k|.so5G.{ۻ-Ud@ɳ3}oT� O߾=iQ IBW-.n>ΓI aَr哏ԖK"F绗~Y=ys'3wO&J'9%KMZ&\B,ȍݫ}TFЇG`y)||w?n&O_< y^J1E$b8,puٿ y*^p5''`er=C?_'9{ .SNŵ(&>.`o$}fR/Ǣ8 !Ay 5cW,s%w I:Blp+焬iд'\TӲ+,&| s柮xpu+HiQ32 ;SfDdZcr\XbXmަ}xh( V1)_9,CytM bO#hϧkyd{#z#>wJQe'J8dTÐlҺ-gp*<Ѫk&//̩ժ աЌvN/a! h.K;_HHTm)\Ki ԣjRxZF] ̾ ξɔ컌>EmL09t+"#¢'m) E?n_zჩw7ϊ {DxQvAFUMLv(Eiť�+*z^&k(U6OӪ#c?'i(_Ld ΤjeΧ3T_S's$] WOͩ0::ɀ֖ysoQFDzhu\^w^Jg/gO(m@),K'.d^5_>׷&F\&gqHr”l1#G`.TJ̵FJ* ;Ѭm ,$Pdnj?/> CP�x 0yvfmR5:ˇ~2a|z@vlJ鏖RiCˀ̿ߏ䛉գi lS J&EB$\c鐏=i_}2^|sj ) drk/ ga̹vaV!51sf; m/Ҹ&,goqꏌ7^/&D6X~C1J0~7q21L:$ǒdRKb|]H|̩?Z8ձZ5ͱųy'yP8l6n)XmI j\kQD"zt$yտY:XM<ҟ` &UR"O;&R[&Kfo`M kzhh,\VR_Ӗs&%dLƖ,8\%mzy:ĩ( ͨk[Mݮ g߼9 J^$>z3X(</eCGV6E Лc()KeGFv&+/0֜&NUnj옳!)Wrs,VסqOEym Ú4Px=4Цl2lNcG:D 5!"o CϽ-X}5:iΠqf6U0֘zSeJY#'^f_͎_r'{75Tl՜Ҳ̟#LjD4T!1 znQ?L_# 6\!H )[Y O ݊VΖx4O7P)PkҴE-î#//YdlR; dx��sIDATŜN x~/>cFScbڃ -Cc4%*DGl BƹXF'p)$r?SO!#nqj׭MK!4 hsow?Br=;R.i4�@yT9V;dz'XAض�,(^�;rZ_CY…d$dGN5ulA L*FHZkq{ix}|%@bܸ^oB \wG f_ U8$sB YF3ppo B؀ϾDsMv/OR0ըq̨5I=L;ҁ=_Ξ<NKOnZ=;'4nW8&EY})ϳb)C{ vdgr$ 949^+v\+G߃(Tc:EӢάf͸ ;i~PMnǣqnø!臲VD%%NLȟq/%m;|љTu2ec(I)ƮǤgX^ \DqImNW'PBN ;.Ll*Py C¾姌}GfT=r+iE(zR>OٟcQcY\}dJ>(2ՠUG٘t|mfsy1?d\+*24 O5Qkg~ eoi4^Vп1ٷ,bZ_f̪3,Ѕpo`(.tz/72aSC^q :3|VZmDw/9rhϺ\77{8'p+LDo8Mփ.<O8`$Co^/#{Ç?*@&ZOT~(Kk1M3zF Nȏo%VX)1<ŭa)'z=%9zKNò HHw2t,Ҽ.>i@i{kVyڕ}- aRӗ1wD<2bfW-2qF׵O;5)CȎ-WkSSJ|d 6C-;jjM5gkLM,p,ߐ/n(؂ n8XbY2mGe3a4 9QIMIBG]D X(3<m/]~;V 3 \=L>kZsVbdz+g{ee,Y g#juwvY'wM "9zKA#S_xg 3~B2eJtާn =+^eJ 脫űr)( #t|/3㉟/P#ҏnCQr+ڎ*->`}y9S`[|dGˮ/%:Aj v*ĵ 8VȒLbOowΦ[*!gYLs,ClOs7Zf=WO 䬏Oy�BPbՇgr`4%2xN a#W7'Vc'Ժ>Zy4FOeЖkJD!1_G6@hp6fedဦU>aim+i3KFS6Dy~'ol-H61>ye{=vBh䐣2J^z7M,w|}iF) mӧZ-Zrh$l(*[f)y5qOH'_vLSWXݸޕ)%j}5%8m9sGtN x˝X;ܛeS,CfrQo{_cAρ(Fv/x:dc-Md+/<;Mi):|P˿K@r.6K#AZL϶?y>-c(~,+V2b ieG`(]=s}qHLؤwÆ1؄g<oc6 ?ʄ /y+|~3YsXnםBYMg^^~}Y1Ya`0s˔5<s >fL-LBZ^Dq? L`ByL\q(_j!vF.Ոf )0Y 3/e NG`(8r::+͚|�}ڎO©U~Mq)aԡMS3cu}#h*1l1̤6_w J73{G 4վ7X $&|>m�w/O:I$՜5zT,6.=7u8=S?(gTƈrc:4Mi":c~U}K74ٟYz|Uv L>(/zcx*54}|,/3Ah)R^ ,|_5|&gM(lwJvap]!.v_?cC':BիS5}Ssw}lj <(#4}u}s"nj@ r}BܨW1g G_?w u+>AJR=,QD 0vvbR13Ykd9(#4}u}_u昑T5N8f@ @  ϗ�@ @ 1#@ @P@nj@ @ A!3@ @ p@ @ 1#@ @P@d17?wsO[ȩo=)5L܉:(^5[}ĵ Ń}NE8Ū{zN@ X�WaXOo!gw2QN=ֱ0+`sMӕ@ @ oe wbdwrYwd";g=#bȒ9%:%9S(HNJ @ D6/낫iHF5zJfUU~r<i4-ii@ �)oQB U�S])[r2.)Yސi@ �q*S$'$7A9+@ @ 1 =amVT-Sg:p|xiI.JyN <^Ǝ65ʗsE*{=OήRƅn4=?ϠOt2ӯF:.LmFQk�}"cP:({|"DZ@ @ !I%$:^Ε=mGNrkzմxWQ>ҵ/ӽIDx95K瑸W^'&)6ߵ~ }2}Nn N㼈g'8|#+#wZ*OL?"xv-c܁]_]K[ZrR*m" v~빕|!߃,yKa[4"@ @ 3x_}~'Gmfٗ)c&0c.&Q<>2'E2LQ,ܲc7Xʤ-O�=bت`JV#Xy7ǎne=x^B|?‘\yH<W\葭қdH˖/ec y.Ȍofі=?USPANn98oNBB,޺[ӷΉ_f5T@  o08\CyN_ԓ)iL|=J8Ee4�RݓNh9�O淣Ø"UL>'Q) эɡե^Oh>b˷Ae,{EHl;R[<Ю.Ōuh`g.z_{(:H<w-fW -f!bKJQ%oHR8Ү<.d?z|V1WfrR*U8mwDr#TRfEx\.e_Ch<t^HANn PY≟z&CAʙ@ @ Ȅh8Sۤ^j(sKi@ I8wca hϜ2IȔ:es 8Pڊ3L u3-#<I2/Li\]]p-[+@ªhR\J`F#d@j۽cUSFER=zG4Ȥ2)󬾵>evj<>{mtFKv$u%+ӧyL@ @ @63rN?ut''857) fթ_ <4a ȅ=AtiUִ|9B%uAv^ +٠ &nrӘ�kN$[<VQ׆tm䖑szU&эdIzC#@ Ac^نe]p540E3rܘP2ѨD*`#(DG�r"š-2E!i}3L"$=%ܘRȅqtHhǢd#˜d6Tmd!N T9^g$68Rż@ @ 9x*669AOWO 4Lb4ߍ^y_)z=0WʬdxlDJnF/^ZV_Z_FF @ @Ip@ @ d|1YI($osOF}}Քj4FD+eӞ$k=t LԩWMJi+a/f\ @ A0[;d@ I=ODB}}BCBQT agLZTBy4#<NT26Iy}'<ΰ:BCr[9A.?"ظ|F@ @ dcr)CkYePBp*cu}>WpIJOU:qD$+RΘ5tx {Q'j'hʺqM)h$uɋĩs a8|11R1탉,@/@ 31&5N/+fnA ,b]lV Ӏ/ABE.dQؘ^c~{~ vEb{8jyɡ>M"q[Կswc\a)&[I[ Dcܱ?@ @ c=H $ _J)U6WHvث,1tHޡC4w/g$Z žb`шa#oG7jp;'zfX˥xЖ^˻%by{>=E͘nx:}|=~q*]cqOc fN.O۵gADzSPz ݐM_!$:tg:Qa7vw?!Mq??V/cZM&d`P) aС?$- * eP,jYB0 67"6!ҡ AtKP "u#ޯ}~_ǭֳm$�����o u&f۶\֋df%XHg~E!O~<Y1g$<CzݢΑw 3T7dZu:?P}Q.Hߠn=侩Hy-C֞~ӯԝqLY}:X32U~nm}Xt cA#W�����9)=c\"fzR&5Ji:56]5x)P+*6kw׀#m,tEGA7TdM.o:)E>3何U,Pqy)s o_OiEQ-U(͡%Jm43����`WE�����Ȃ�����_������d �����@������d �����@|wHwj*%����IENDB`�����������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/src/�������������������������������������������������������������������������0000775�0000000�0000000�00000000000�13634420343�0015541�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/src/Makefile.am��������������������������������������������������������������0000664�0000000�0000000�00000002110�13634420343�0017567�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# Copyright 2016 Artem Savkov <artem.savkov@gmail.com> # Copyright 2017 Alex Schroeder <alex@gnu.org> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. libdir = $(plugindir) lib_LTLIBRARIES = mastodon.la mastodon_la_CFLAGS = \ $(BITLBEE_CFLAGS) \ $(GLIB_CFLAGS) \ -Wall mastodon_la_LDFLAGS = \ -module \ -avoid-version \ $(BITLBEE_LIBS) \ $(GLIB_LIBS) mastodon_la_SOURCES = \ mastodon.c \ mastodon.h \ mastodon-http.c \ mastodon-http.h \ mastodon-lib.c \ mastodon-lib.h \ rot13.c \ rot13.h ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/src/mastodon-http.c����������������������������������������������������������0000664�0000000�0000000�00000012432�13634420343�0020510�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������/***************************************************************************\ * * * BitlBee - An IRC to IM gateway * * Simple module to facilitate Mastodon functionality. * * * * Copyright 2009 Geert Mulders <g.c.w.m.mulders@gmail.com> * * Copyright 2017 Alex Schroeder <alex@gnu.org> * * * * This library is free software; you can redistribute it and/or * * modify it under the terms of the GNU Lesser General Public * * License as published by the Free Software Foundation, version * * 2.1. * * * * This library is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * * Lesser General Public License for more details. * * * * You should have received a copy of the GNU Lesser General Public License * * along with this library; if not, write to the Free Software Foundation, * * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA * * * ****************************************************************************/ /***************************************************************************\ * * * Some functions within this file have been copied from other files within * * BitlBee. * * * ****************************************************************************/ #include "mastodon.h" #include "bitlbee.h" #include "url.h" #include "misc.h" #include "base64.h" #include "oauth.h" #include <ctype.h> #include <errno.h> #include "mastodon-http.h" static char *mastodon_url_append(char *url, char *key, char *value) { char *key_encoded = g_strndup(key, 3 * strlen(key)); http_encode(key_encoded); char *value_encoded = g_strndup(value, 3 * strlen(value)); http_encode(value_encoded); char *retval; if (strlen(url) != 0) { retval = g_strdup_printf("%s&%s=%s", url, key_encoded, value_encoded); } else { retval = g_strdup_printf("%s=%s", key_encoded, value_encoded); } g_free(key_encoded); g_free(value_encoded); return retval; } /** * Do a request. * This is actually pretty generic function... Perhaps it should move to the lib/http_client.c */ struct http_request *mastodon_http(struct im_connection *ic, char *url_string, http_input_function func, gpointer data, http_method_t method, char **arguments, int arguments_len) { struct mastodon_data *md = ic->proto_data; void *ret = NULL; char *url_arguments = g_strdup(""); char *request_method = "GET"; switch (method) { case HTTP_GET: request_method = "GET"; break; case HTTP_PUT: request_method = "PUT"; break; case HTTP_POST: request_method = "POST"; break; case HTTP_DELETE: request_method = "DELETE"; break; } // Construct the url arguments. if (arguments_len != 0) { int i; for (i = 0; i < arguments_len; i += 2) { char *tmp = mastodon_url_append(url_arguments, arguments[i], arguments[i + 1]); g_free(url_arguments); url_arguments = tmp; } } url_t *base_url = NULL; if (strstr(url_string, "://")) { base_url = g_new0(url_t, 1); if (!url_set(base_url, url_string)) { goto error; } } // Make the request. GString *request = g_string_new(""); g_string_printf(request, "%s %s%s%s HTTP/1.1\r\n" "Host: %s\r\n" "User-Agent: BitlBee " BITLBEE_VERSION "\r\n" "Authorization: Bearer %s\r\n", request_method, base_url ? base_url->file : url_string, method == HTTP_GET && url_arguments[0] ? "?" : "", method == HTTP_GET && url_arguments[0] ? url_arguments : "", base_url ? base_url->host : md->url_host, md->oauth2_access_token); // Do POST stuff.. if (method != HTTP_GET) { // Append the Content-Type and url-encoded arguments. g_string_append_printf(request, "Content-Type: application/x-www-form-urlencoded\r\n" "Content-Length: %zd\r\n\r\n%s", strlen(url_arguments), url_arguments); } else { // Append an extra \r\n to end the request... g_string_append(request, "\r\n"); } if (base_url) { ret = http_dorequest(base_url->host, base_url->port, base_url->proto == PROTO_HTTPS, request->str, func, data); } else { ret = http_dorequest(md->url_host, md->url_port, md->url_ssl, request->str, func, data); } g_string_free(request, TRUE); error: g_free(url_arguments); g_free(base_url); return ret; } ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/src/mastodon-http.h����������������������������������������������������������0000664�0000000�0000000�00000004146�13634420343�0020520�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������/***************************************************************************\ * * * BitlBee - An IRC to IM gateway * * Simple module to facilitate Mastodon functionality. * * * * Copyright 2009 Geert Mulders <g.c.w.m.mulders@gmail.com> * * Copyright 2017 Alex Schroeder <alex@gnu.org> * * * * This library is free software; you can redistribute it and/or * * modify it under the terms of the GNU Lesser General Public * * License as published by the Free Software Foundation, version * * 2.1. * * * * This library is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * * Lesser General Public License for more details. * * * * You should have received a copy of the GNU Lesser General Public License * * along with this library; if not, write to the Free Software Foundation, * * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA * * * ****************************************************************************/ #pragma once #include "nogaim.h" #include "http_client.h" typedef enum { HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_DELETE, } http_method_t; struct http_request *mastodon_http(struct im_connection *ic, char *url_string, http_input_function func, gpointer data, http_method_t method, char** arguments, int arguments_len); ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/src/mastodon-lib.c�����������������������������������������������������������0000664�0000000�0000000�00000364143�13634420343�0020310�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������/***************************************************************************\ * * * BitlBee - An IRC to IM gateway * * Simple module to facilitate Mastodon functionality. * * * * Copyright 2009-2010 Geert Mulders <g.c.w.m.mulders@gmail.com> * * Copyright 2010-2013 Wilmer van der Gaast <wilmer@gaast.net> * * Copyright 2017-2019 Alex Schroeder <alex@gnu.org> * * * * This library is free software; you can redistribute it and/or * * modify it under the terms of the GNU Lesser General Public * * License as published by the Free Software Foundation, version * * 2.1. * * * * This library is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * * Lesser General Public License for more details. * * * * You should have received a copy of the GNU Lesser General Public License * * along with this library; if not, write to the Free Software Foundation, * * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA * * * ****************************************************************************/ /* For strptime(): */ #if (__sun) #else #define _XOPEN_SOURCE #endif #include "mastodon-http.h" #include "mastodon.h" #include "rot13.h" #include "bitlbee.h" #include "url.h" #include "misc.h" #include "base64.h" #include "mastodon-lib.h" #include "oauth2.h" #include "json.h" #include "json_util.h" #include <assert.h> #include <ctype.h> #include <errno.h> typedef enum { MT_HOME, MT_LOCAL, MT_FEDERATED, MT_HASHTAG, MT_LIST, } mastodon_timeline_type_t; typedef enum { ML_STATUS, ML_NOTIFICATION, } mastodon_list_type_t; struct mastodon_list { mastodon_list_type_t type; GSList *list; }; struct mastodon_account { guint64 id; char *display_name; char *acct; }; struct mastodon_status { time_t created_at; char *spoiler_text; char *spoiler_text_case_folded; /* for filtering */ char *text; char *content; /* same as text without CW and NSFW prefixes */ char *content_case_folded; /* for filtering */ char *url; struct mastodon_account *account; guint64 id; mastodon_visibility_t visibility; guint64 reply_to; GSList *tags; GSList *mentions; mastodon_timeline_type_t subscription; /* This status was created by a timeline subscription */ gboolean is_notification; /* This status was created from a notification */ }; typedef enum { MN_MENTION = 1, MN_REBLOG, MN_FAVOURITE, MN_FOLLOW, } mastodon_notification_type_t; struct mastodon_notification { guint64 id; mastodon_notification_type_t type; time_t created_at; struct mastodon_account *account; struct mastodon_status *status; }; struct mastodon_report { struct im_connection *ic; guint64 account_id; guint64 status_id; char *comment; }; typedef enum { MF_HOME = 0x00001, MF_NOTIFICATIONS = 0x00002, MF_PUBLIC = 0x00004, MF_THREAD = 0x00008, } mastodon_filter_type_t; struct mastodon_filter { guint64 id; char* phrase; char* phrase_case_folded; mastodon_filter_type_t context; gboolean irreversible; gboolean whole_word; time_t expires_in; }; struct mastodon_command { struct im_connection *ic; guint64 id; guint64 id2; gboolean extra; char *str; char *undo; char *redo; gpointer *data; mastodon_command_type_t command; }; /** * Frees a mastodon_account struct. */ static void ma_free(struct mastodon_account *ma) { if (ma == NULL) { return; } g_free(ma->display_name); g_free(ma->acct); g_free(ma); } /** * Creates a duplicate of an account. */ static struct mastodon_account *ma_copy(struct mastodon_account *ma0) { if (ma0 == NULL) { return NULL; } struct mastodon_account *ma = g_new0(struct mastodon_account, 1); ma->id = ma0->id; ma->display_name = g_strdup(ma0->display_name); ma->acct = g_strdup(ma0->acct); return ma; } /** * Frees a mastodon_status struct. */ static void ms_free(struct mastodon_status *ms) { if (ms == NULL) { return; } g_free(ms->spoiler_text); g_free(ms->text); g_free(ms->content); g_free(ms->url); ma_free(ms->account); g_slist_free_full(ms->tags, g_free); g_slist_free_full(ms->mentions, (GDestroyNotify) ma_free); g_free(ms); } /** * Frees a mastodon_notification struct. */ static void mn_free(struct mastodon_notification *mn) { if (mn == NULL) { return; } ma_free(mn->account); ms_free(mn->status); g_free(mn); } /** * Free a mastodon_list struct. * type is the type of list the struct holds. */ static void ml_free(struct mastodon_list *ml) { GSList *l; if (ml == NULL) { return; } for (l = ml->list; l; l = g_slist_next(l)) { if (ml->type == ML_STATUS) { ms_free((struct mastodon_status *) l->data); } else if (ml->type == ML_NOTIFICATION) { mn_free((struct mastodon_notification *) l->data); } } g_slist_free(ml->list); g_free(ml); } /** * Frees a mastodon_report struct. */ static void mr_free(struct mastodon_report *mr) { if (mr == NULL) { return; } g_free(mr->comment); g_free(mr); } /** * Frees a mastodon_filter struct. */ static void mf_free(struct mastodon_filter *mf) { if (mf == NULL) { return; } g_free(mf->phrase); g_free(mf); } /** * Frees a mastodon_command struct. Note that the groupchat c doesn't need to be freed. It is maintained elsewhere. */ static void mc_free(struct mastodon_command *mc) { if (mc == NULL) { return; } g_free(mc->str); g_free(mc->undo); g_free(mc->redo); g_free(mc); } /** * Compare status elements */ static gint mastodon_compare_elements(gconstpointer a, gconstpointer b) { struct mastodon_status *a_status = (struct mastodon_status *) a; struct mastodon_status *b_status = (struct mastodon_status *) b; if (a_status->created_at < b_status->created_at) { return -1; } else if (a_status->created_at > b_status->created_at) { return 1; } else { return 0; } } /** * Add a buddy if it is not already added, set the status to logged in. */ static void mastodon_add_buddy(struct im_connection *ic, gint64 id, char *name, const char *fullname) { struct mastodon_data *md = ic->proto_data; // Check if the buddy is already in the buddy list. if (!bee_user_by_handle(ic->bee, ic, name)) { // The buddy is not in the list, add the buddy and set the status to logged in. imcb_add_buddy(ic, name, NULL); imcb_rename_buddy(ic, name, fullname); bee_user_t *bu = bee_user_by_handle(ic->bee, ic, name); struct mastodon_user_data *mud = (struct mastodon_user_data*) bu->data; mud->account_id = id; if (md->flags & MASTODON_MODE_CHAT) { /* Necessary so that nicks always get translated to the exact Mastodon username. */ imcb_buddy_nick_hint(ic, name, name); if (md->timeline_gc) { imcb_chat_add_buddy(md->timeline_gc, name); } } else if (md->flags & MASTODON_MODE_MANY) { imcb_buddy_status(ic, name, OPT_LOGGED_IN, NULL, NULL); } } } /* Warning: May return a malloc()ed value, which will be free()d on the next call. Only for short-term use. NOT THREADSAFE! */ char *mastodon_parse_error(struct http_request *req) { static char *ret = NULL; json_value *root, *err; g_free(ret); ret = NULL; if (req->body_size > 0) { root = json_parse(req->reply_body, req->body_size); err = json_o_get(root, "error"); if (err && err->type == json_string && err->u.string.length) { ret = g_strdup_printf("%s (%s)", req->status_string, err->u.string.ptr); } json_value_free(root); } return ret ? ret : req->status_string; } /* WATCH OUT: This function might or might not destroy your connection. Sub-optimal indeed, but just be careful when this returns NULL! */ static json_value *mastodon_parse_response(struct im_connection *ic, struct http_request *req) { char path[64] = "", *s; if ((s = strchr(req->request, ' '))) { path[sizeof(path) - 1] = '\0'; strncpy(path, s + 1, sizeof(path) - 1); if ((s = strchr(path, '?')) || (s = strchr(path, ' '))) { *s = '\0'; } } if (req->status_code != 200) { mastodon_log(ic, "Error: %s returned status code %s", path, mastodon_parse_error(req)); if (!(ic->flags & OPT_LOGGED_IN)) { imc_logout(ic, TRUE); } return NULL; } json_value *ret; if ((ret = json_parse(req->reply_body, req->body_size)) == NULL) { imcb_error(ic, "Error: %s return data that could not be parsed as JSON", path); } return ret; } /** * For Mastodon 2, all id attributes in the REST API responses, including attributes that end in _id, are now returned * as strings instead of integers. This is because large integers cannot be encoded in JSON losslessly, and all IDs in * Mastodon are now bigint (Ruby on Rails: bigint uses 64 bits, signed, guint64 is 64 bits, unsigned). We are assuming * no negative ids. */ static guint64 mastodon_json_int64(const json_value *v) { guint64 id; if (v->type == json_integer) { return v->u.integer; // Mastodon 1 } else if (v->type == json_string && *v->u.string.ptr && parse_int64(v->u.string.ptr, 10, &id)) { return id; // Mastodon 2 } return 0; } /* These two functions are useful to debug all sorts of callbacks. */ static void mastodon_log_object(struct im_connection *ic, json_value *node, int prefix); static void mastodon_log_array(struct im_connection *ic, json_value *node, int prefix); struct mastodon_account *mastodon_xt_get_user(const json_value *node) { struct mastodon_account *ma; json_value *jv; ma = g_new0(struct mastodon_account, 1); ma->display_name = g_strdup(json_o_str(node, "display_name")); ma->acct = g_strdup(json_o_str(node, "acct")); if ((jv = json_o_get(node, "id")) && (ma->id = mastodon_json_int64(jv))) { return ma; } ma_free(ma); return NULL; } /* This is based on strip_html but in addition to what Bitlbee does, we treat p like br. */ void mastodon_strip_html(char *in) { char *start = in; char out[strlen(in) + 1]; char *s = out; memset(out, 0, sizeof(out)); while (*in) { if (*in == '<') { if (g_strncasecmp(in + 1, "/p>", 3) == 0) { *(s++) = '\n'; in += 4; } else { *(s++) = *(in++); } } else { *(s++) = *(in++); } } strcpy(start, out); strip_html(start); } mastodon_visibility_t mastodon_parse_visibility(char *value) { if (g_strcasecmp(value, "public") == 0) { return MV_PUBLIC; } else if (g_strcasecmp(value, "unlisted") == 0) { return MV_UNLISTED; } else if (g_strcasecmp(value, "private") == 0) { return MV_PRIVATE; } else if (g_strcasecmp(value, "direct") == 0) { return MV_DIRECT; } else { return MV_UNKNOWN; } } /** * Here, we have a similar setup as for mastodon_chained_account_function. The flow is as follows: the * mastodon_command_handler() calls mastodon_unknown_list_delete(). This sets up the mastodon_command (mc). It then * calls mastodon_with_named_list() and passes along a callback, mastodon_http_list_delete(). It uses * mastodon_chained_list() to extract the list id and store it in mc, and calls the next handler, * mastodon_list_delete(). This is a mastodon_chained_command_function! It doesn't have to check whether ic is live. */ typedef void (*mastodon_chained_command_function)(struct im_connection *ic, struct mastodon_command *mc); /** * This is the wrapper around callbacks that need to search for the list id in a list result. Note that list titles are * case-sensitive. */ static void mastodon_chained_list(struct http_request *req, mastodon_chained_command_function func) { struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; if (!g_slist_find(mastodon_connections, ic)) { goto finally; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; goto finally; } if (parsed->type != json_array || parsed->u.array.length == 0) { mastodon_log(ic, "You seem to have no lists defined. " "Create one using 'list create <title>'."); goto finish; } int i; guint64 id = 0; char *title = mc->str; for (i = 0; i < parsed->u.array.length; i++) { json_value *a = parsed->u.array.values[i]; json_value *it; if (a->type == json_object && (it = json_o_get(a, "id")) && g_strcmp0(title, json_o_str(a, "title")) == 0) { id = mastodon_json_int64(it); break; } } if (!id) { mastodon_log(ic, "There is no list called '%s'. " "Use 'list' to show existing lists.", title); goto finish; } else { mc->id = id; func(ic, mc); /* If successful, we need to keep mc for one more request. */ json_value_free(parsed); return; } finish: /* We didn't find what we were looking for and need to free the parsed data. */ json_value_free(parsed); finally: /* We've encountered a problem and we need to free the mastodon_command. */ mc_free(mc); } /** * Wrapper which sets up the first callback for functions acting on a list. For every command, the list has to be * searched, first. The callback you provide must used mastodon_chained_list() to extract the list id and then call the * reall callback. */ void mastodon_with_named_list(struct im_connection *ic, struct mastodon_command *mc, http_input_function func) { mastodon_http(ic, MASTODON_LIST_URL, func, mc, HTTP_GET, NULL, 0); } /** * Function to fill a mastodon_status struct. */ static struct mastodon_status *mastodon_xt_get_status(const json_value *node, struct im_connection *ic) { struct mastodon_status *ms = {0}; const json_value *rt = NULL; const json_value *text_value = NULL; const json_value *spoiler_value = NULL; const json_value *url_value = NULL; GSList *media = NULL; gboolean nsfw = FALSE; gboolean use_cw1 = g_strcasecmp(set_getstr(&ic->acc->set, "hide_sensitive"), "advanced_rot13") == 0; if (node->type != json_object) { return FALSE; } ms = g_new0(struct mastodon_status, 1); JSON_O_FOREACH(node, k, v) { if (strcmp("content", k) == 0 && v->type == json_string && *v->u.string.ptr) { text_value = v; } if (strcmp("spoiler_text", k) == 0 && v->type == json_string && *v->u.string.ptr) { spoiler_value = v; } else if (strcmp("url", k) == 0 && v->type == json_string) { url_value = v; } else if (strcmp("reblog", k) == 0 && v->type == json_object) { rt = v; } else if (strcmp("created_at", k) == 0 && v->type == json_string) { struct tm parsed; /* Very sensitive to changes to the formatting of this field. :-( Also assumes the timezone used is UTC since C time handling functions suck. */ if (strptime(v->u.string.ptr, MASTODON_TIME_FORMAT, &parsed) != NULL) { ms->created_at = mktime_utc(&parsed); } } else if (strcmp("visibility", k) == 0 && v->type == json_string && *v->u.string.ptr) { ms->visibility = mastodon_parse_visibility(v->u.string.ptr); } else if (strcmp("account", k) == 0 && v->type == json_object) { ms->account = mastodon_xt_get_user(v); } else if (strcmp("id", k) == 0) { ms->id = mastodon_json_int64(v); } else if (strcmp("in_reply_to_id", k) == 0) { ms->reply_to = mastodon_json_int64(v); } else if (strcmp("tags", k) == 0 && v->type == json_array) { GSList *l = NULL; int i; for (i = 0; i < v->u.array.length; i++) { json_value *tag = v->u.array.values[i]; if (tag->type == json_object) { const char *name = json_o_str(tag, "name"); if (name) { l = g_slist_prepend(l, g_strdup(name)); } } } ms->tags = l; } else if (strcmp("mentions", k) == 0 && v->type == json_array) { GSList *l = NULL; int i; gint64 id = set_getint(&ic->acc->set, "account_id"); for (i = 0; i < v->u.array.length; i++) { struct mastodon_account *ma = mastodon_xt_get_user(v->u.array.values[i]); /* Skip the current user in mentions since we're only interested in this information for replies where * we'll never want to mention ourselves. */ if (ma && ma->id != id) l = g_slist_prepend(l, ma); } ms->mentions = l; } else if (strcmp("sensitive", k) == 0 && v->type == json_boolean) { nsfw = v->u.boolean; } else if (strcmp("media_attachments", k) == 0 && v->type == json_array) { int i; for (i = 0; i < v->u.array.length; i++) { json_value *attachment = v->u.array.values[i]; if (attachment->type == json_object) { // text_url is preferred because that's what the UI also copies // into the message; also ignore values such as /files/original/missing.png const char *url = json_o_str(attachment, "text_url"); if (!url || !*url || strncmp(url, "http", 4)) { url = json_o_str(attachment, "url"); if (!url || !*url || strncmp(url, "http", 4)) { url = json_o_str(attachment, "remote_url"); } } if (url && *url && strncmp(url, "http", 4) == 0) { media = g_slist_prepend(media, (char *) url); // discarding const qualifier } } } } } if (rt) { struct mastodon_status *rms = mastodon_xt_get_status(rt, ic); if (rms) { /* Alternatively, we could free ms and just use rms, but we'd have to overwrite rms->account * with ms->account, change rms->text, and maybe more. */ ms->text = g_strdup_printf("boosted @%s: %s", rms->account->acct, rms->text); ms->id = rms->id; ms->url = rms->url; // adopt rms->url = NULL; g_slist_free_full(ms->tags, g_free); ms->tags = rms->tags; // adopt rms->tags = NULL; g_slist_free_full(ms->mentions, (GDestroyNotify) ma_free); ms->mentions = rms->mentions; // adopt rms->mentions = NULL; /* add original author to mentions of boost if not ourselves */ gint64 id = set_getint(&ic->acc->set, "account_id"); if (rms->account->id != id) { ms->mentions = g_slist_prepend(ms->mentions, rms->account); // adopt rms->account = NULL; } ms_free(rms); } } else if (ms->id) { if (url_value) { ms->url = g_strdup(url_value->u.string.ptr); } // build status text GString *s = g_string_new(NULL); if (spoiler_value) { char *spoiler_text = g_strdup(spoiler_value->u.string.ptr); mastodon_strip_html(spoiler_text); g_string_append_printf(s, "[CW: %s]", spoiler_text); ms->spoiler_text = spoiler_text; ms->spoiler_text_case_folded = g_utf8_casefold(spoiler_text, -1); if (nsfw || !use_cw1) { g_string_append(s, " "); } } if (nsfw) { char *sensitive_flag = set_getstr(&ic->acc->set, "sensitive_flag"); g_string_append(s, sensitive_flag); } if (text_value) { char *text = g_strdup(text_value->u.string.ptr); mastodon_strip_html(text); ms->content = g_strdup(text); ms->content_case_folded = g_utf8_casefold(text, -1); char *fmt = "%s"; if (spoiler_value && use_cw1) { char *wrapped = NULL; char **cwed = NULL; rot13(text); // "\001CW1 \001" = 6 bytes, there's also a nick length issue we take into account. // there's also irc_format_timestamp which can add like 28 bytes or something. wrapped = word_wrap(text, IRC_WORD_WRAP - 6 - MAX_NICK_LENGTH - 28); g_free(text); text = wrapped; cwed = g_strsplit(text, "\n", -1); // easier than a regex g_free(text); text = g_strjoinv("\001\n\001CW1 ", cwed); // easier than a replace g_strfreev(cwed); fmt = "\n\001CW1 %s\001"; // add a newline at the start because that makes word wrap a lot easier (and because it matches the web UI better) } else if (spoiler_value && g_strcasecmp(set_getstr(&ic->acc->set, "hide_sensitive"), "rot13") == 0) { rot13(text); } else if (spoiler_value && set_getbool(&ic->acc->set, "hide_sensitive")) { g_free(text); text = g_strdup(ms->url); if (text) { fmt = "[hidden: %s]"; } else { fmt = "[hidden]"; } } g_string_append_printf(s, fmt, text); g_free(text); } GSList *l = NULL; for (l = media; l; l = l->next) { // TODO maybe support hiding media when it's marked NSFW. // (note that only media is hidden when it's marked NSFW. the text still shows.) // (note that we already don't show media, since this is all text, but IRC clients might.) char *url = l->data; if ((text_value && strstr(text_value->u.string.ptr, url)) || strstr(s->str, url)) { // skip URLs already in the text continue; } if (s->len) { g_string_append(s, " "); } g_string_append(s, url); } ms->text = g_string_free(s, FALSE); // we keep the data } g_slist_free(media); // elements are pointers into node and don't need to be freed if (ms->text && ms->account && ms->id) { return ms; } ms_free(ms); return NULL; } /** * Function to fill a mastodon_notification struct. */ static struct mastodon_notification *mastodon_xt_get_notification(const json_value *node, struct im_connection *ic) { if (node->type != json_object) { return FALSE; } struct mastodon_notification *mn = g_new0(struct mastodon_notification, 1); JSON_O_FOREACH(node, k, v) { if (strcmp("id", k) == 0) { mn->id = mastodon_json_int64(v); } else if (strcmp("created_at", k) == 0 && v->type == json_string) { struct tm parsed; /* Very sensitive to changes to the formatting of this field. :-( Also assumes the timezone used is UTC since C time handling functions suck. */ if (strptime(v->u.string.ptr, MASTODON_TIME_FORMAT, &parsed) != NULL) { mn->created_at = mktime_utc(&parsed); } } else if (strcmp("account", k) == 0 && v->type == json_object) { mn->account = mastodon_xt_get_user(v); } else if (strcmp("status", k) == 0 && v->type == json_object) { mn->status = mastodon_xt_get_status(v, ic); } else if (strcmp("type", k) == 0 && v->type == json_string) { if (strcmp(v->u.string.ptr, "mention") == 0) { mn->type = MN_MENTION; } else if (strcmp(v->u.string.ptr, "reblog") == 0) { mn->type = MN_REBLOG; } else if (strcmp(v->u.string.ptr, "favourite") == 0) { mn->type = MN_FAVOURITE; } else if (strcmp(v->u.string.ptr, "follow") == 0) { mn->type = MN_FOLLOW; } } } if (mn->type) { return mn; } mn_free(mn); return NULL; } static gboolean mastodon_xt_get_status_list(struct im_connection *ic, const json_value *node, struct mastodon_list *ml) { ml->type = ML_STATUS; if (node->type != json_array) { return FALSE; } int i; for (i = 0; i < node->u.array.length; i++) { struct mastodon_status *ms = mastodon_xt_get_status(node->u.array.values[i], ic); if (ms) { /* Code that calls this will display the toots in the home timeline, i.e. the account channel. This is true * right after a login and when displaying search results or a toot context. */ ms->subscription = MT_HOME; ml->list = g_slist_prepend(ml->list, ms); } } ml->list = g_slist_reverse(ml->list); return TRUE; } static gboolean mastodon_xt_get_notification_list(struct im_connection *ic, const json_value *node, struct mastodon_list *ml) { ml->type = ML_NOTIFICATION; if (node->type != json_array) { return FALSE; } int i; for (i = 0; i < node->u.array.length; i++) { struct mastodon_notification *mn = mastodon_xt_get_notification(node->u.array.values[i], ic); if (mn) { ml->list = g_slist_prepend(ml->list, mn); } } ml->list = g_slist_reverse(ml->list); return TRUE; } /* Will log messages either way. Need to keep track of IDs for stream deduping. Plus, show_ids is on by default and I don't see why anyone would disable it. */ static char *mastodon_msg_add_id(struct im_connection *ic, struct mastodon_status *ms, const char *prefix) { struct mastodon_data *md = ic->proto_data; int reply_to = -1; int idx = -1; /* See if we know this status and if we know the status this one is replying to. */ int i; for (i = 0; i < MASTODON_LOG_LENGTH; i++) { if (ms->reply_to && md->log[i].id == ms->reply_to) { reply_to = i; } if (md->log[i].id == ms->id) { idx = i; } if (idx != -1 && (!ms->reply_to || reply_to != -1)) { break; } } /* If we didn't find the status, it's new and needs an id, and we want to record who said it, and when they said * it, and who they mentioned, and the spoiler they used. We need to do this in two places: the md->log, and per * user in the mastodon_user_data (mud). */ if (idx == -1) { idx = md->log_id = (md->log_id + 1) % MASTODON_LOG_LENGTH; md->log[idx].id = ms->id; md->log[idx].visibility = ms->visibility; g_slist_free_full(md->log[idx].mentions, g_free); md->log[idx].mentions = g_slist_copy_deep(ms->mentions, (GCopyFunc) ma_copy, NULL); g_free(md->log[idx].spoiler_text); md->log[idx].spoiler_text = g_strdup(ms->spoiler_text); // no problem if NULL gint64 id = set_getint(&ic->acc->set, "account_id"); if (ms->account->id == id) { /* If this is our own status, use a fake bu without data since we can't be found by handle. This * will allow us to reply to our own messages, for example. */ md->log[idx].bu = &mastodon_log_local_user; } else { bee_user_t *bu = bee_user_by_handle(ic->bee, ic, ms->account->acct); struct mastodon_user_data *mud = bu->data; if (ms->id > mud->last_id) { mud->visibility = ms->visibility; if (ms->visibility == MV_DIRECT) { /* We need to keep the timestamp for direct communications in addition to the regular timestamp so * that if somebody sends us a direct message (which shows up in a query buffer) and then posts a * public message (which shows up in the regular channel) and we reply in the query buffer then we * want our in_reply_to to refer to the older direct message, no the newer public message (see * mastodon_buddy_msg which calls mastodon_post_message using MASTODON_REPLY). At the same time, if * somebody sends a public message first, followed by a direct message, and re reply in the regular * channel (!) then we want our reply to still work (mastodon_handle_command calls * mastodon_post_message with MASTODON_MAYBE_REPLY). */ mud->last_direct_id = ms->id; mud->last_direct_time = ms->created_at; } mud->last_id = ms->id; mud->last_time = ms->created_at; g_slist_free_full(mud->mentions, (GDestroyNotify) ma_free); mud->mentions = g_slist_copy_deep(ms->mentions, (GCopyFunc) ma_copy, NULL); g_free(mud->spoiler_text); mud->spoiler_text = g_strdup(ms->spoiler_text); // no problem if NULL } md->log[idx].bu = bu; } } if (set_getbool(&ic->acc->set, "show_ids")) { if (reply_to != -1) { return g_strdup_printf("\002[\002%02x->%02x\002]\002 %s%s", idx, reply_to, prefix, ms->text); } else { return g_strdup_printf("\002[\002%02x\002]\002 %s%s", idx, prefix, ms->text); } } else { if (*prefix) { return g_strconcat(prefix, ms->text, NULL); } else { return NULL; } } } /** * Helper function for mastodon_status_show_chat. */ static void mastodon_status_show_chat1(struct im_connection *ic, gboolean me, struct groupchat *c, char *msg, struct mastodon_status *ms) { if (me) { mastodon_visibility_t default_visibility = mastodon_default_visibility(ic); if (ms->visibility == default_visibility) { imcb_chat_log(c, "You: %s", msg ? msg : ms->text); } else { imcb_chat_log(c, "You, %s: %s", mastodon_visibility(ms->visibility), msg ? msg : ms->text); } } else { imcb_chat_msg(c, ms->account->acct, msg ? msg : ms->text, 0, ms->created_at); } } /** * Function that is called to see the statuses in a group chat. If the user created appropriate group chats (see setup * in mastodon_chat_join()), then we have extra streams providing the toots for these streams. The subscription * attribute gives us a basic hint of how the status wants to be sorted. Now, we also have a TIMELINE command, which * allows us to simulate the result. In this case, we can't be sure that appropriate group chats exist and thus we need * to put those statuses into the user timeline if they do not. */ static void mastodon_status_show_chat(struct im_connection *ic, struct mastodon_status *status) { gint64 id = set_getint(&ic->acc->set, "account_id"); gboolean me = (status->account->id == id); if (!me) { /* MUST be done before mastodon_msg_add_id() to avoid #872. */ mastodon_add_buddy(ic, status->account->id, status->account->acct, status->account->display_name); } char *msg = mastodon_msg_add_id(ic, status, ""); gboolean seen = FALSE; struct mastodon_user_data *mud; struct groupchat *c; bee_user_t *bu; GSList *l; switch (status->subscription) { case MT_LIST: /* Add the status to existing group chats with a topic matching any the lists this user is part of. */ bu = bee_user_by_handle(ic->bee, ic, status->account->acct); mud = (struct mastodon_user_data*) bu->data; for (l = mud->lists; l; l = l->next) { char *title = l->data; struct groupchat *c = bee_chat_by_title(ic->bee, ic, title); if (c) { mastodon_status_show_chat1(ic, me, c, msg, status); seen = TRUE; } } break; case MT_HASHTAG: /* Add the status to any other existing group chats whose title matches one of the tags, including the hash! */ for (l = status->tags; l; l = l->next) { char *tag = l->data; char *title = g_strdup_printf("#%s", tag); struct groupchat *c = bee_chat_by_title(ic->bee, ic, title); if (c) { mastodon_status_show_chat1(ic, me, c, msg, status); seen = TRUE; } g_free(title); } break; case MT_LOCAL: /* If there is an appropriate group chat, do not put it in the user timeline. */ c = bee_chat_by_title(ic->bee, ic, "local"); if (c) { mastodon_status_show_chat1(ic, me, c, msg, status); seen = TRUE; } break; case MT_FEDERATED: /* If there is an appropriate group chat, do not put it in the user timeline. */ c = bee_chat_by_title(ic->bee, ic, "federated"); if (c) { mastodon_status_show_chat1(ic, me, c, msg, status); seen = TRUE; } break; case MT_HOME: /* This is the default */ break; } if (!seen) { c = mastodon_groupchat_init(ic); mastodon_status_show_chat1(ic, me, c, msg, status); } g_free(msg); } /** * Function that is called to see statuses as private messages. */ static void mastodon_status_show_msg(struct im_connection *ic, struct mastodon_status *ms) { struct mastodon_data *md = ic->proto_data; char from[MAX_STRING] = ""; char *text = NULL; gint64 id = set_getint(&ic->acc->set, "account_id"); gboolean me = (ms->account->id == id); char *name = set_getstr(&ic->acc->set, "name"); if (md->flags & MASTODON_MODE_ONE) { char *prefix = g_strdup_printf("\002<\002%s\002>\002 ", ms->account->acct); text = mastodon_msg_add_id(ic, ms, prefix); /* may return NULL */ g_free(prefix); g_strlcpy(from, name, sizeof(from)); imcb_buddy_msg(ic, from, text ? text : ms->text, 0, ms->created_at); } else if (!me) { mastodon_add_buddy(ic, ms->account->id, ms->account->acct, ms->account->display_name); text = mastodon_msg_add_id(ic, ms, ""); /* may return NULL */ imcb_buddy_msg(ic, *from ? from : ms->account->acct, text ? text : ms->text, 0, ms->created_at); } else if (!ms->mentions) { text = mastodon_msg_add_id(ic, ms, "You, direct, but without mentioning anybody: "); /* may return NULL */ mastodon_log(ic, text ? text : ms->text); } else { text = mastodon_msg_add_id(ic, ms, "You, direct: "); /* At this point we have to cheat: if this is the echo of a message we're sending, we still want this message to * show up in the query buffer where we're chatting with somebody. So even though it is "from us" we're going to * fake that it is "from the recipient". And worse: we want to do this for all the buddies if we're chatting * directly with multiple people. */ GSList *l; for (l = ms->mentions; l; l = g_slist_next(l)) { bee_user_t *bu; struct mastodon_account *ma = (struct mastodon_account *) l->data; if ((bu = bee_user_by_handle(ic->bee, ic, ma->acct))) { mastodon_add_buddy(ic, ma->id, ma->acct, ma->display_name); imcb_buddy_msg(ic, ma->acct, text ? text : ms->text, 0, ms->created_at); } } } g_free(text); } struct mastodon_status *mastodon_notification_to_status(struct mastodon_notification *notification) { struct mastodon_account *ma = notification->account; struct mastodon_status *ms = notification->status; if (ma == NULL) { // Should not happen. ma = g_new0(struct mastodon_account, 1); ma->acct = g_strdup("anon"); ma->display_name = g_strdup("Unknown"); } /* The status in the notification was written by you, it's account is your account, but now somebody else is doing * something with it. We want to avoid the extra You at the beginning, "You: [01] @foo boosted your status: bla" * should be "<foo> [01] boosted your status: bla" or "<foo> followed you". So we're creating a fake status with a * copy of the notification account. */ if (ms == NULL) { /* Could be a FOLLOW notification without status. */ ms = g_new0(struct mastodon_status, 1); ms->account = ma_copy(notification->account); ms->created_at = notification->created_at; /* This ensures that ms will be freed when the notification is freed. */ notification->status = ms; } else { /* Adopt the account from the notification. The account will be freed when the notification frees the status. */ ma_free(ms->account); ms->account = ma; notification->account = NULL; } /* Make sure filters from the notification context know that this status is from a notification. */ ms->is_notification = TRUE; char *original = ms->text; switch (notification->type) { case MN_MENTION: // this is fine original = NULL; break; case MN_REBLOG: ms->text = g_strdup_printf("boosted your status: %s", original); break; case MN_FAVOURITE: ms->text = g_strdup_printf("favourited your status: %s", original); break; case MN_FOLLOW: ms->text = g_strdup_printf("[%s] followed you", ma->display_name); break; } g_free(original); return ms; } /** * Test whether a filter applies to the text. */ gboolean mastodon_filter_matches_it(char *text, struct mastodon_filter *mf) { if (!text) return FALSE; if (!mf->whole_word) { return strstr(text, mf->phrase_case_folded) != NULL; } else { /* Find the character at the beginning of the phrase and the character at the end of the phrase. */ int len = strlen(mf->phrase_case_folded); gunichar p1 = g_utf8_get_char(mf->phrase_case_folded); gunichar p2 = g_utf8_get_char(g_utf8_prev_char(mf->phrase_case_folded + len)); gboolean p1_is_alnum = g_unichar_isalnum(p1); gboolean p2_is_alnum = g_unichar_isalnum(p2); /* Start searching from the beginning. When we continue searching because a match is not at word boundaries, just skip a single character because matches can overlap. */ gchar *s = text; while ((s = strstr (s, mf->phrase_case_folded))) { /* At the beginning of the text counts as a word boundary. If the beginning of the phrase is not * alphanumeric, we don't care about word boundaries. */ if (s != text && p1_is_alnum) { /* Find the character before the match. This could potentially be the first character. */ gunichar c = g_utf8_get_char(g_utf8_prev_char(s)); /* If this is also an alphanumeric character, then this is not a word boundary. */ if (g_unichar_isalnum(c)) { s = g_utf8_next_char(s); continue; } } /* If the beginning of the phrase is not alphanumeric, we don't care about word boundaries. */ if (p2_is_alnum) { /* Find the character after the match. This could potentially be the zero byte. */ gunichar c = g_utf8_get_char(g_utf8_prev_char(s) + len); /* If this is the end of the string, or an alphanumeric character, then this is not a word boundary. */ if (!c || g_unichar_isalnum(c)) { s = g_utf8_next_char(s); continue; } } /* Otherwise we're golden. */ return TRUE; } return FALSE; } } /** * Test whether a filter applies to the status. */ gboolean mastodon_filter_matches(struct mastodon_status *ms, struct mastodon_filter *mf) { if (!ms || !mf || !mf->phrase_case_folded) return FALSE; return (mastodon_filter_matches_it(ms->content_case_folded, mf) || mastodon_filter_matches_it(ms->spoiler_text_case_folded, mf)); } /** * Show the status to the user. */ static void mastodon_status_show(struct im_connection *ic, struct mastodon_status *ms) { struct mastodon_data *md = ic->proto_data; if (ms->account == NULL || ms->text == NULL) { return; } /* Must check all the filters. */ GSList *l; for (l = md->filters; l; l = g_slist_next(l)) { struct mastodon_filter *mf = (struct mastodon_filter *) l->data; /* MF_HOME filter applies to the home timeline, MF_PUBLIC applies to the local and federated public timelines, * MF_NOTIFICATION applies to any notifications received. */ if (((mf->context & MF_HOME && ms->subscription == MT_HOME) || (mf->context & MF_PUBLIC && (ms->subscription == MT_LOCAL || ms->subscription == MT_FEDERATED)) || (mf->context & MF_NOTIFICATIONS && ms->is_notification) || mf->context & MF_THREAD) && mastodon_filter_matches(ms, mf)) { /* Do not show. */ return; } } /* Deduplicating only affects the previous status shown. Thus, if we got mentioned in a toot by a user that we're * following, chances are that both events will arrive in sequence. In this case, the second one will be skipped. * This will also work when flushing timelines after connecting: notification and status update should be close to * each other. This will fail if the stream is really busy. Critically, it won't suppress statuses from later * context and timeline requests. */ if (ms->id == md->seen_id) { return; } else { md->seen_id = ms->id; } /* Grrrr. Would like to do this during parsing, but can't access settings from there. */ if (set_getbool(&ic->acc->set, "strip_newlines")) { strip_newlines(ms->text); } /* By default, everything except direct messages goes into a channel. */ if (md->flags & MASTODON_MODE_CHAT && ms->visibility != MV_DIRECT) { mastodon_status_show_chat(ic, ms); } else { mastodon_status_show_msg(ic, ms); } } static void mastodon_notification_show(struct im_connection *ic, struct mastodon_notification *notification) { gboolean show = TRUE; switch (notification->type) { case MN_MENTION: show = !set_getbool(&ic->acc->set, "hide_mentions"); break; case MN_REBLOG: show = !set_getbool(&ic->acc->set, "hide_boosts"); break; case MN_FAVOURITE: show = !set_getbool(&ic->acc->set, "hide_favourites"); break; case MN_FOLLOW: show = !set_getbool(&ic->acc->set, "hide_follows"); break; } if (show) mastodon_status_show(ic, mastodon_notification_to_status(notification)); } /** * Add exactly one notification to the timeline. */ static void mastodon_stream_handle_notification(struct im_connection *ic, json_value *parsed, mastodon_timeline_type_t subscription) { struct mastodon_notification *mn = mastodon_xt_get_notification(parsed, ic); if (mn) { /* A follow notification has no status and thus cannot be assigned a subsription (see mastodon_timeline_type_t). * But if there is a status associated with the notification, we know where it came from. */ if (mn->status) mn->status->subscription = subscription; mastodon_notification_show(ic, mn); mn_free(mn); } } /** * Add exactly one status to the timeline. */ static void mastodon_stream_handle_update(struct im_connection *ic, json_value *parsed, mastodon_timeline_type_t subscription) { struct mastodon_status *ms = mastodon_xt_get_status(parsed, ic); if (ms) { ms->subscription = subscription; mastodon_status_show(ic, ms); ms_free(ms); } } /* Let the user know if a status they have recently seen was deleted. If we can't find the deleted status in our list of * recently seen statuses, ignore the event. */ static void mastodon_stream_handle_delete(struct im_connection *ic, json_value *parsed) { struct mastodon_data *md = ic->proto_data; guint64 id = mastodon_json_int64(parsed); if (id) { int i; for (i = 0; i < MASTODON_LOG_LENGTH; i++) { if (md->log[i].id == id) { mastodon_log(ic, "Status %02x was deleted.", i); md->log[i].id = 0; // prevent future references return; } } } else { mastodon_log(ic, "Error parsing a deletion event."); } } static void mastodon_stream_handle_event(struct im_connection *ic, mastodon_evt_flags_t evt_type, json_value *parsed, mastodon_timeline_type_t subscription) { if (evt_type == MASTODON_EVT_UPDATE) { mastodon_stream_handle_update(ic, parsed, subscription); } else if (evt_type == MASTODON_EVT_NOTIFICATION) { mastodon_stream_handle_notification(ic, parsed, subscription); } else if (evt_type == MASTODON_EVT_DELETE) { mastodon_stream_handle_delete(ic, parsed); } else { mastodon_log(ic, "Ignoring event type %d", evt_type); } } /** * When streaming, we also want to tag the events appropriately. This only affects updates, for now. */ static void mastodon_http_stream(struct http_request *req, mastodon_timeline_type_t subscription) { struct im_connection *ic = req->data; struct mastodon_data *md = ic->proto_data; int len = 0; char *nl; if (!g_slist_find(mastodon_connections, ic)) { return; } if ((req->flags & HTTPC_EOF) || !req->reply_body) { md->streams = g_slist_remove (md->streams, req); imcb_error(ic, "Stream closed (%s)", req->status_string); imc_logout(ic, TRUE); return; } /* It doesn't matter which stream sent us something. */ ic->flags |= OPT_PONGED; /* https://github.com/tootsuite/documentation/blob/master/Using-the-API/Streaming-API.md https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format */ if (req->reply_body[0] == ':' && (nl = strchr(req->reply_body, '\n'))) { // found a comment such as the heartbeat ":thump\n" len = nl - req->reply_body + 1; goto end; } else if (!(nl = strstr(req->reply_body, "\n\n"))) { // wait until we have a complete event return; } // include the two newlines at the end len = nl - req->reply_body + 2; if (len > 0) { char *p; mastodon_evt_flags_t evt_type = MASTODON_EVT_UNKNOWN; // assuming space after colon if (strncmp(req->reply_body, "event: ", 7) == 0) { p = req->reply_body + 7; if (strncmp(p, "update\n", 7) == 0) { evt_type = MASTODON_EVT_UPDATE; p += 7; } else if (strncmp(p, "notification\n", 13) == 0) { evt_type = MASTODON_EVT_NOTIFICATION; p += 13; } else if (strncmp(p, "delete\n", 7) == 0) { evt_type = MASTODON_EVT_DELETE; p += 7; } } if (evt_type != MASTODON_EVT_UNKNOWN) { GString *data = g_string_new(""); char* q; while (strncmp(p, "data: ", 6) == 0) { p += 6; q = strchr(p, '\n'); p[q-p] = '\0'; g_string_append(data, p); p = q + 1; } json_value *parsed; if ((parsed = json_parse(data->str, data->len))) { mastodon_stream_handle_event(ic, evt_type, parsed, subscription); json_value_free(parsed); } g_string_free(data, TRUE); } } end: http_flush_bytes(req, len); /* We might have multiple events */ if (req->body_size > 0) { mastodon_http_stream(req, subscription); } } static void mastodon_http_stream_user(struct http_request *req) { mastodon_http_stream(req, MT_HOME); } static void mastodon_http_stream_hashtag(struct http_request *req) { mastodon_http_stream(req, MT_HASHTAG); } static void mastodon_http_stream_local(struct http_request *req) { mastodon_http_stream(req, MT_LOCAL); } static void mastodon_http_stream_federated(struct http_request *req) { mastodon_http_stream(req, MT_FEDERATED); } static void mastodon_http_stream_list(struct http_request *req) { mastodon_http_stream(req, MT_LIST); } /** * Make sure a request continues stream instead of closing. */ void mastodon_stream(struct im_connection *ic, struct http_request *req) { struct mastodon_data *md = ic->proto_data; if (req) { req->flags |= HTTPC_STREAMING; md->streams = g_slist_prepend(md->streams, req); } } /** * Open the user (home) timeline. */ void mastodon_open_user_stream(struct im_connection *ic) { struct http_request *req = mastodon_http(ic, MASTODON_STREAMING_USER_URL, mastodon_http_stream_user, ic, HTTP_GET, NULL, 0); mastodon_stream(ic, req); } /** * Open a stream for a hashtag timeline and return the request. */ struct http_request *mastodon_open_hashtag_stream(struct im_connection *ic, char *hashtag) { char *args[2] = { "tag", hashtag, }; struct http_request *req = mastodon_http(ic, MASTODON_STREAMING_HASHTAG_URL, mastodon_http_stream_hashtag, ic, HTTP_GET, args, 2); mastodon_stream(ic, req); return req; } /** * Part two of the first callback: now we have mc->id. Now we're good to go. */ void mastodon_list_stream(struct im_connection *ic, struct mastodon_command *mc) { char *args[2] = { "list", g_strdup_printf("%" G_GINT64_FORMAT, mc->id), }; struct http_request *req = mastodon_http(ic, MASTODON_STREAMING_LIST_URL, mastodon_http_stream_list, ic, HTTP_GET, args, 2); mastodon_stream(ic, req); /* We cannot return req here because this is a callback (as we had to figure out the list id before getting here). * This is why we must rely on the groupchat being part of mastodon_command (mc). */ struct groupchat *c = (struct groupchat *) mc->data; c->data = req; } /** * First callback to show the stream for a list. We need to parse the lists and find the one we're looking for, then * make our next request with the list id. */ static void mastodon_http_list_stream(struct http_request *req) { mastodon_chained_list(req, mastodon_list_stream); } void mastodon_open_unknown_list_stream(struct im_connection *ic, struct groupchat *c, char *title) { struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; mc->data = (gpointer *) c; mc->str = g_strdup(title); mastodon_with_named_list(ic, mc, mastodon_http_list_stream); } /** * Open a stream for the local timeline and return the request. */ struct http_request *mastodon_open_local_stream(struct im_connection *ic) { struct http_request *req = mastodon_http(ic, MASTODON_STREAMING_LOCAL_URL, mastodon_http_stream_local, ic, HTTP_GET, NULL, 0); mastodon_stream(ic, req); return req; } /** * Open a stream for the federated timeline and return the request. */ struct http_request *mastodon_open_federated_stream(struct im_connection *ic) { struct http_request *req = mastodon_http(ic, MASTODON_STREAMING_FEDERATED_URL, mastodon_http_stream_federated, ic, HTTP_GET, NULL, 0); mastodon_stream(ic, req); return req; } /** * Look for the Link header and remember the URL to the next page of results. This will be used by the "more" command. */ static void mastodon_handle_header(struct http_request *req, mastodon_more_t more_type) { struct im_connection *ic = req->data; // remember the URL to fetch more if there is a header saying that there is more (URL in angled brackets) char *header = NULL; if ((header = get_rfc822_header(req->reply_headers, "Link", 0))) { char *url = NULL; gboolean next = FALSE; int i; for (i = 0; header[i]; i++) { if (header[i] == '<') { url = header + i + 1; } else if (url && header[i] == '>') { header[i] = 0; if (strncmp(header + i + 1, "; rel=\"next\"", 12) == 0) { next = TRUE; break; } else { url = NULL; } } } struct mastodon_data *md = ic->proto_data; g_free(md->next_url); md->next_url = NULL; if (next) md->next_url = g_strdup(url); md->more_type = more_type; g_free(header); } } /** * Handle a request whose response contains nothing but statuses. Note that we expect req->data to be an im_connection, * not a mastodon_command (or NULL). */ static void mastodon_http_timeline(struct http_request *req, mastodon_timeline_type_t subscription) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } if (parsed->type != json_array || parsed->u.array.length == 0) { mastodon_log(ic, "No statuses found in this timeline."); goto finish; } mastodon_handle_header(req, MASTODON_MORE_STATUSES); // Show in reverse order! int i; for (i = parsed->u.array.length - 1; i >= 0 ; i--) { json_value *node = parsed->u.array.values[i]; struct mastodon_status *ms = mastodon_xt_get_status(node, ic); if (ms) { ms->subscription = subscription; mastodon_status_show(ic, ms); ms_free(ms); } } finish: json_value_free(parsed); } static void mastodon_http_hashtag_timeline(struct http_request *req) { mastodon_http_timeline(req, MT_HASHTAG); } void mastodon_hashtag_timeline(struct im_connection *ic, char *hashtag) { char *url = g_strdup_printf(MASTODON_HASHTAG_TIMELINE_URL, hashtag); mastodon_http(ic, url, mastodon_http_hashtag_timeline, ic, HTTP_GET, NULL, 0); g_free(url); } static void mastodon_http_home_timeline(struct http_request *req) { mastodon_http_timeline(req, MT_HOME); } void mastodon_home_timeline(struct im_connection *ic) { mastodon_http(ic, MASTODON_HOME_TIMELINE_URL, mastodon_http_home_timeline, ic, HTTP_GET, NULL, 0); } static void mastodon_http_local_timeline(struct http_request *req) { mastodon_http_timeline(req, MT_LOCAL); } void mastodon_local_timeline(struct im_connection *ic) { char *args[2] = { "local", "1", }; mastodon_http(ic, MASTODON_PUBLIC_TIMELINE_URL, mastodon_http_local_timeline, ic, HTTP_GET, args, 2); } static void mastodon_http_federated_timeline(struct http_request *req) { mastodon_http_timeline(req, MT_FEDERATED); } void mastodon_federated_timeline(struct im_connection *ic) { mastodon_http(ic, MASTODON_PUBLIC_TIMELINE_URL, mastodon_http_federated_timeline, ic, HTTP_GET, NULL, 0); } /** * Second callback to show the timeline for a list. We finally got a list of statuses. */ static void mastodon_http_list_timeline2(struct http_request *req) { /* We have used a mastodon_command (mc) all this time, but now it's time to forget about it and use an im_connection * (ic) instead. */ struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; req->data = ic; mc_free(mc); mastodon_http_timeline(req, MT_LIST); } /** * Part two of the first callback to show the timeline for a list. In mc->id we have our list id. */ void mastodon_list_timeline(struct im_connection *ic, struct mastodon_command *mc) { char *url = g_strdup_printf(MASTODON_LIST_TIMELINE_URL, mc->id); mastodon_http(ic, url, mastodon_http_list_timeline2, mc, HTTP_GET, NULL, 0); g_free(url); } /** * First callback to show the timeline for a list. We need to parse the lists and find the one we're looking for, then * make our next request with the list id. */ static void mastodon_http_list_timeline(struct http_request *req) { mastodon_chained_list(req, mastodon_list_timeline); } /** * Timeline for a named list. This requires two callbacks: the first to find the list id, the second one to do the * actual work. */ void mastodon_unknown_list_timeline(struct im_connection *ic, char *title) { struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; mc->str = g_strdup(title); mastodon_with_named_list(ic, mc, mastodon_http_list_timeline); } /** * Call this one after receiving timeline/notifications. Show to user * once we have both. */ void mastodon_flush_timeline(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; struct mastodon_list *home_timeline; struct mastodon_list *notifications; GSList *output = NULL; GSList *l; if (md == NULL) { return; } imcb_connected(ic); /* Wait until we have all the data we need. */ if (!(md->flags & MASTODON_GOT_TIMELINE) || !(md->flags & MASTODON_GOT_NOTIFICATIONS) || !(md->flags & MASTODON_GOT_FILTERS)) { return; } home_timeline = md->home_timeline_obj; notifications = md->notifications_obj; if (home_timeline && home_timeline->list) { for (l = home_timeline->list; l; l = g_slist_next(l)) { output = g_slist_insert_sorted(output, l->data, mastodon_compare_elements); } } if (notifications && notifications->list) { for (l = notifications->list; l; l = g_slist_next(l)) { // Skip notifications older than the earliest entry in the timeline. struct mastodon_status *ms = mastodon_notification_to_status((struct mastodon_notification *) l->data); if (output && mastodon_compare_elements(ms, output->data) < 0) { continue; } output = g_slist_insert_sorted(output, ms, mastodon_compare_elements); } } while (output) { struct mastodon_status *ms = output->data; mastodon_status_show(ic, ms); output = g_slist_remove(output, ms); } ml_free(home_timeline); ml_free(notifications); g_slist_free(output); md->flags &= ~(MASTODON_GOT_TIMELINE | MASTODON_GOT_NOTIFICATIONS | MASTODON_GOT_FILTERS); md->home_timeline_obj = md->notifications_obj = NULL; } /** * Callback for getting the home timeline. This runs in parallel to * getting the notifications. */ static void mastodon_http_get_home_timeline(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } struct mastodon_data *md = ic->proto_data; json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } struct mastodon_list *ml = g_new0(struct mastodon_list, 1); mastodon_xt_get_status_list(ic, parsed, ml); json_value_free(parsed); md->home_timeline_obj = ml; md->flags |= MASTODON_GOT_TIMELINE; mastodon_flush_timeline(ic); } /** * Callback for getting the notifications. This runs in parallel to * getting the home timeline. */ static void mastodon_http_get_notifications(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } struct mastodon_data *md = ic->proto_data; json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } struct mastodon_list *ml = g_new0(struct mastodon_list, 1); mastodon_xt_get_notification_list(ic, parsed, ml); json_value_free(parsed); md->notifications_obj = ml; md->flags |= MASTODON_GOT_NOTIFICATIONS; mastodon_flush_timeline(ic); } /** * See mastodon_initial_timeline. */ static void mastodon_get_home_timeline(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; ml_free(md->home_timeline_obj); md->home_timeline_obj = NULL; md->flags &= ~MASTODON_GOT_TIMELINE; mastodon_http(ic, MASTODON_HOME_TIMELINE_URL, mastodon_http_get_home_timeline, ic, HTTP_GET, NULL, 0); } /** * See mastodon_initial_timeline. */ static void mastodon_get_notifications(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; ml_free(md->notifications_obj); md->notifications_obj = NULL; md->flags &= ~MASTODON_GOT_NOTIFICATIONS; mastodon_http(ic, MASTODON_NOTIFICATIONS_URL, mastodon_http_get_notifications, ic, HTTP_GET, NULL, 0); } static void mastodon_get_filters(struct im_connection *ic); /** * Get the initial timeline. This consists of three things: the home timeline, notifications, and filters. During normal * use, the timeline and the notifications are provided via the Streaming API. However, when we connect to an instance * we want to load the home timeline and notifications and sort them in a meaningful way. We use flags: * MASTODON_GOT_TIMELINE to indicate that we now have home timeline, MASTODON_GOT_NOTIFICATIONS to indicate that we now * have notifications, and MASTODON_GOT_FILTERS to indicate that we now have filters . All callbacks will attempt to * flush the initial timeline, but this will only succeed if all three flags are set. */ void mastodon_initial_timeline(struct im_connection *ic) { imcb_log(ic, "Getting home timeline"); mastodon_get_home_timeline(ic); mastodon_get_notifications(ic); mastodon_get_filters(ic); return; } /** * Callback for getting notifications manually. */ static void mastodon_http_notifications(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } if (parsed->type != json_array || parsed->u.array.length == 0) { mastodon_log(ic, "No notifications found."); goto finish; } mastodon_handle_header(req, MASTODON_MORE_NOTIFICATIONS); // Show in reverse order! int i; for (i = parsed->u.array.length - 1; i >= 0 ; i--) { json_value *node = parsed->u.array.values[i]; struct mastodon_notification *mn = mastodon_xt_get_notification(node, ic); if (mn) { mastodon_notification_show(ic, mn); mn_free(mn); } } finish: json_value_free(parsed); } /** * Notifications are usually shown by the Streaming API, and when showing the initial timeline after connecting. In * order to allow manual review (and going through past notifications using the more command, we need yet another way to * get notifications. */ void mastodon_notifications(struct im_connection *ic) { mastodon_http(ic, MASTODON_NOTIFICATIONS_URL, mastodon_http_notifications, ic, HTTP_GET, NULL, 0); } mastodon_visibility_t mastodon_default_visibility(struct im_connection *ic) { return mastodon_parse_visibility(set_getstr(&ic->acc->set, "visibility")); } char *mastodon_visibility(mastodon_visibility_t visibility) { switch (visibility) { case MV_UNKNOWN: case MV_PUBLIC: return "public"; case MV_UNLISTED: return "unlisted"; case MV_PRIVATE: return "private"; case MV_DIRECT: return "direct"; } g_assert(FALSE); // should not happen return NULL; } /** * Generic callback to use after sending a POST request to mastodon when the reply doesn't have any information we need. * All we care about are errors. If got here, there was no error. If you want to tell the user that everything went * fine, call mastodon_http_callback_and_ack instead. This command also stores some information for later use. */ static void mastodon_http_callback(struct http_request *req) { struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } /* Store stuff in the undo/redo stack. */ struct mastodon_data *md = ic->proto_data; md->last_id = 0; struct mastodon_status *ms; switch (mc->command) { case MC_UNKNOWN: break; case MC_POST: ms = mastodon_xt_get_status(parsed, ic); gint64 id = set_getint(&ic->acc->set, "account_id"); if (ms && ms->id && ms->account->id == id) { /* we posted this status */ md->last_id = ms->id; md->last_visibility = ms->visibility; g_free(md->last_spoiler_text); md->last_spoiler_text = ms->spoiler_text; // adopt ms->spoiler_text = NULL; g_slist_free_full(md->mentions, (GDestroyNotify) ma_free); md->mentions = ms->mentions; // adopt ms->mentions = NULL; if(md->undo_type == MASTODON_NEW) { GString *todo = g_string_new (NULL); char *undo = g_strdup_printf("delete %" G_GUINT64_FORMAT, ms->id); /* At this point redoing the reply no longer has the reference to the toot we are replying to (which * only works by looking it up in the mastodon_user_data (mud) or the md->log). That is why we need * to add spoiler_text and visibility to the todo item on our redo list. */ if (ms->spoiler_text) { g_string_append_printf(todo, "cw %s" FS, ms->spoiler_text); } else { g_string_append(todo, "cw" FS); } if (mastodon_default_visibility(ic) != ms->visibility) { g_string_append_printf(todo, "visibility %s" FS, mastodon_visibility(ms->visibility)); } else { g_string_append(todo, "visibility" FS); } if (ms->reply_to) { g_string_append_printf(todo, "reply %" G_GUINT64_FORMAT " ", ms->reply_to); } else { g_string_append(todo, "post "); } g_string_append(todo, ms->content); mastodon_do(ic, todo->str, undo); g_string_free(todo, FALSE); /* data is kept by mastodon_do! */ } else { char *s = g_strdup_printf("delete %" G_GUINT64_FORMAT, ms->id); mastodon_do_update(ic, s); g_free(s); } } break; case MC_FOLLOW: case MC_UNFOLLOW: case MC_BLOCK: case MC_UNBLOCK: case MC_FAVOURITE: case MC_UNFAVOURITE: case MC_PIN: case MC_UNPIN: case MC_ACCOUNT_MUTE: case MC_ACCOUNT_UNMUTE: case MC_STATUS_MUTE: case MC_STATUS_UNMUTE: case MC_BOOST: case MC_UNBOOST: case MC_LIST_CREATE: case MC_LIST_DELETE: case MC_LIST_ADD_ACCOUNT: case MC_LIST_REMOVE_ACCOUNT: case MC_FILTER_CREATE: case MC_FILTER_DELETE: case MC_DELETE: md->last_id = 0; mastodon_do(ic, mc->redo, mc->undo); // adopting these strings: do not free them at the end mc->redo = mc->undo = 0; break; } mc_free(mc); json_value_free(parsed); } /** * Call the generic callback function and print an acknowledgement for the user. * Commands should use mastodon_post instead. */ static void mastodon_http_callback_and_ack(struct http_request *req) { struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; mastodon_http_callback(req); // this frees mc if (req->status_code == 200) { mastodon_log(ic, "Command processed successfully"); } } /** * Return a static string n spaces long. No deallocation needed. */ static char *indent(int n) { char *spaces = " "; int len = 10; return n > len ? spaces : spaces + len - n; } /** * Return a static yes or no string. No deallocation needed. */ static char *yes_or_no(int bool) { return bool ? "yes" : "no"; } /** * Log a JSON array out to the channel. When you call it, use a * prefix of 0. Recursive calls will then indent nested objects. */ static void mastodon_log_array(struct im_connection *ic, json_value *node, int prefix) { int i; for (i = 0; i < node->u.array.length; i++) { json_value *v = node->u.array.values[i]; char *s; switch (v->type) { case json_object: if (v->u.object.values == 0) { mastodon_log(ic, "%s{}", indent(prefix)); break; } mastodon_log(ic, "%s{", indent(prefix)); mastodon_log_object (ic, v, prefix + 1); mastodon_log(ic, "%s}", indent(prefix)); break; case json_array: if (v->u.array.length == 0) { mastodon_log(ic, "%s[]", indent(prefix)); break; } mastodon_log(ic, "%s[", indent(prefix)); int i; for (i = 0; i < v->u.array.length; i++) { mastodon_log_object (ic, node->u.array.values[i], prefix + 1); } mastodon_log(ic, "%s]", indent(prefix)); break; case json_string: s = g_strdup(v->u.string.ptr); mastodon_strip_html(s); mastodon_log(ic, "%s%s", indent(prefix), s); g_free(s); break; case json_double: mastodon_log(ic, "%s%f", indent(prefix), v->u.dbl); break; case json_integer: mastodon_log(ic, "%s%d", indent(prefix), v->u.boolean); break; case json_boolean: mastodon_log(ic, "%s%s: %s", indent(prefix), yes_or_no(v->u.boolean)); break; case json_null: mastodon_log(ic, "%snull", indent(prefix)); break; case json_none: mastodon_log(ic, "%snone", indent(prefix)); break; } } } /** * Log a JSON object out to the channel. When you call it, use a * prefix of 0. Recursive calls will then indent nested objects. */ static void mastodon_log_object(struct im_connection *ic, json_value *node, int prefix) { char *s; JSON_O_FOREACH(node, k, v) { switch (v->type) { case json_object: if (v->u.object.values == 0) { mastodon_log(ic, "%s%s: {}", indent(prefix), k); break; } mastodon_log(ic, "%s%s: {", indent(prefix), k); mastodon_log_object (ic, v, prefix + 1); mastodon_log(ic, "%s}", indent(prefix)); break; case json_array: if (v->u.array.length == 0) { mastodon_log(ic, "%s%s: []", indent(prefix), k); break; } mastodon_log(ic, "%s%s: [", indent(prefix), k); mastodon_log_array(ic, v, prefix + 1); mastodon_log(ic, "%s]", indent(prefix)); break; case json_string: s = g_strdup(v->u.string.ptr); mastodon_strip_html(s); mastodon_log(ic, "%s%s: %s", indent(prefix), k, s); g_free(s); break; case json_double: mastodon_log(ic, "%s%s: %f", indent(prefix), k, v->u.dbl); break; case json_integer: mastodon_log(ic, "%s%s: %d", indent(prefix), k, v->u.boolean); break; case json_boolean: mastodon_log(ic, "%s%s: %s", indent(prefix), k, yes_or_no(v->u.boolean)); break; case json_null: mastodon_log(ic, "%s%s: null", indent(prefix), k); break; case json_none: mastodon_log(ic, "%s%s: unknown type", indent(prefix), k); break; } } } /** * Generic callback which simply logs the JSON response to the * channel. */ static void mastodon_http_log_all(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } if (parsed->type == json_object) { mastodon_log_object(ic, parsed, 0); } else if (parsed->type == json_array) { mastodon_log_array(ic, parsed, 0); } else { mastodon_log(ic, "Sadly, the response to this request is not a JSON object or array."); } json_value_free(parsed); } /** * Function to POST a new status to mastodon. */ void mastodon_post_status(struct im_connection *ic, char *msg, guint64 in_reply_to, mastodon_visibility_t visibility, char *spoiler_text) { char *args[8] = { "status", msg, "visibility", mastodon_visibility(visibility), "spoiler_text", spoiler_text, "in_reply_to_id", g_strdup_printf("%" G_GUINT64_FORMAT, in_reply_to) }; int count = 8; struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; mc->command = MC_POST; if (!in_reply_to) { count -= 2; } if (!spoiler_text) { count -= 2; if (in_reply_to) { args[4] = args[6]; args[5] = args[7]; // we have 2 pointers to the in_reply_to_id string now, } } // No need to acknowledge the processing of a post: we will get notified. mastodon_http(ic, MASTODON_STATUS_POST_URL, mastodon_http_callback, mc, HTTP_POST, args, count); g_free(args[7]); // but we only free one of them! } /** * Generic POST request taking a numeric ID. The format string must contain one placeholder for the ID, like * "/accounts/%" G_GINT64_FORMAT "/mute". */ void mastodon_post(struct im_connection *ic, char *format, mastodon_command_type_t command, guint64 id) { struct mastodon_data *md = ic->proto_data; struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; if (md->undo_type == MASTODON_NEW) { mc->command = command; switch (command) { case MC_UNKNOWN: case MC_POST: case MC_DELETE: case MC_LIST_CREATE: case MC_LIST_DELETE: case MC_LIST_ADD_ACCOUNT: case MC_LIST_REMOVE_ACCOUNT: case MC_FILTER_CREATE: case MC_FILTER_DELETE: /* These commands should not be calling mastodon_post. Instead, call mastodon_post_status or whatever else * is required. */ break; case MC_FOLLOW: mc->redo = g_strdup_printf("follow %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("unfollow %" G_GUINT64_FORMAT, id); break; case MC_UNFOLLOW: mc->redo = g_strdup_printf("unfollow %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("follow %" G_GUINT64_FORMAT, id); break; case MC_BLOCK: mc->redo = g_strdup_printf("block %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("unblock %" G_GUINT64_FORMAT, id); break; case MC_UNBLOCK: mc->redo = g_strdup_printf("unblock %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("block %" G_GUINT64_FORMAT, id); break; case MC_FAVOURITE: mc->redo = g_strdup_printf("favourite %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("unfavourite %" G_GUINT64_FORMAT, id); break; case MC_UNFAVOURITE: mc->redo = g_strdup_printf("unfavourite %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("favourite %" G_GUINT64_FORMAT, id); break; case MC_PIN: mc->redo = g_strdup_printf("pin %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("unpin %" G_GUINT64_FORMAT, id); break; case MC_UNPIN: mc->redo = g_strdup_printf("unpin %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("pin %" G_GUINT64_FORMAT, id); break; case MC_ACCOUNT_MUTE: mc->redo = g_strdup_printf("mute user %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("unmute user %" G_GUINT64_FORMAT, id); break; case MC_ACCOUNT_UNMUTE: mc->redo = g_strdup_printf("unmute user %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("mute user %" G_GUINT64_FORMAT, id); break; case MC_STATUS_MUTE: mc->redo = g_strdup_printf("mute %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("unmute %" G_GUINT64_FORMAT, id); break; case MC_STATUS_UNMUTE: mc->redo = g_strdup_printf("unmute %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("mute %" G_GUINT64_FORMAT, id); break; case MC_BOOST: mc->redo = g_strdup_printf("boost %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("unboost %" G_GUINT64_FORMAT, id); break; case MC_UNBOOST: mc->redo = g_strdup_printf("unboost %" G_GUINT64_FORMAT, id); mc->undo = g_strdup_printf("boost %" G_GUINT64_FORMAT, id); break; } } char *url = g_strdup_printf(format, id); mastodon_http(ic, url, mastodon_http_callback_and_ack, mc, HTTP_POST, NULL, 0); g_free(url); } void mastodon_http_status_delete(struct http_request *req) { struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } /* Maintain undo/redo list. */ struct mastodon_status *ms = mastodon_xt_get_status(parsed, ic); struct mastodon_data *md = ic->proto_data; gint64 id = set_getint(&ic->acc->set, "account_id"); if (ms && ms->id && ms->account->id == id) { /* we deleted our own status */ md->last_id = ms->id; mc->redo = g_strdup_printf("delete %" G_GUINT64_FORMAT, ms->id); GString *todo = g_string_new (NULL); if (ms->spoiler_text) { g_string_append_printf(todo, "cw %s" FS, ms->spoiler_text); } else { g_string_append(todo, "cw" FS); } if (mastodon_default_visibility(ic) != ms->visibility) { g_string_append_printf(todo, "visibility %s" FS, mastodon_visibility(ms->visibility)); } else { g_string_append(todo, "visibility" FS); } if (ms->reply_to) { g_string_append_printf(todo, "reply %" G_GUINT64_FORMAT " ", ms->reply_to); } else { g_string_append(todo, "post "); } g_string_append(todo, ms->content); mc->undo = todo->str; g_string_free(todo, FALSE); /* data is kept by mc! */ } char *url = g_strdup_printf(MASTODON_STATUS_URL, mc->id); // No need to acknowledge the processing of the delete: we will get notified. mastodon_http(ic, url, mastodon_http_callback, mc, HTTP_DELETE, NULL, 0); g_free(url); } /** * Helper for all functions that need to act on a status before they * can do anything else. Provide a function to use as a callback. This * callback will get the status back and will need to call * mastodon_xt_get_status and do something with it. */ void mastodon_with_status(struct mastodon_command *mc, guint64 id, http_input_function func) { char *url = g_strdup_printf(MASTODON_STATUS_URL, id); mastodon_http(mc->ic, url, func, mc, HTTP_GET, NULL, 0); g_free(url); } /** * Delete a status. In order to ensure that we can undo and redo this, * fetch the status to be deleted before actually deleting it. */ void mastodon_status_delete(struct im_connection *ic, guint64 id) { struct mastodon_data *md = ic->proto_data; struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; if (md->undo_type == MASTODON_NEW) { mc->command = MC_DELETE; mc->id = id; mastodon_with_status(mc, id, mastodon_http_status_delete); } else { // Shortcut char *url = g_strdup_printf(MASTODON_STATUS_URL, id); // No need to acknowledge the processing of the delete: we will get notified. mastodon_http(ic, url, mastodon_http_callback, mc, HTTP_DELETE, NULL, 0); g_free(url); } } /** * Callback for reporting a user for sending spam. */ void mastodon_http_report(struct http_request *req) { struct mastodon_report *mr = req->data; struct im_connection *ic = mr->ic; if (!g_slist_find(mastodon_connections, ic)) { goto finally; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; goto finally; } struct mastodon_status *ms = mastodon_xt_get_status(parsed, ic); if (ms) { mr->account_id = ms->account->id; ms_free(ms); } else { mastodon_log(ic, "Error: could not fetch toot to report."); goto finish; } char *args[6] = { "account_id", g_strdup_printf("%" G_GUINT64_FORMAT, mr->account_id), "status_ids", g_strdup_printf("%" G_GUINT64_FORMAT, mr->status_id), // API allows an array, here "comment", mr->comment, }; struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; mastodon_http(ic, MASTODON_REPORT_URL, mastodon_http_callback_and_ack, mc, HTTP_POST, args, 6); g_free(args[1]); g_free(args[3]); finish: ms_free(ms); json_value_free(parsed); finally: // The report structure was created by mastodon_report and has // to be freed under all circumstances. mr_free(mr); } /** * Report a user. Since all we have is the id of the offending status, * we need to retrieve the status, first. */ void mastodon_report(struct im_connection *ic, guint64 id, char *comment) { char *url = g_strdup_printf(MASTODON_STATUS_URL, id); struct mastodon_report *mr = g_new0(struct mastodon_report, 1); mr->ic = ic; mr->status_id = id; mr->comment = g_strdup(comment); mastodon_http(ic, url, mastodon_http_report, mr, HTTP_POST, NULL, 0); g_free(url); } /** * Callback for search. */ void mastodon_http_search(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } json_value *v; gboolean found = FALSE; /* hashtags */ if ((v = json_o_get(parsed, "hashtags")) && (v->type == json_array) && (v->u.array.length > 0)) { found = TRUE; int i; for (i = 0; i < v->u.array.length; i++) { json_value *s; s = v->u.array.values[i]; if (s->type == json_string) { mastodon_log(ic, "#%s", s->u.string.ptr); } } } /* accounts */ if ((v = json_o_get(parsed, "accounts")) && (v->type == json_array) && (v->u.array.length > 0)) { found = TRUE; int i; for (i = 0; i < v->u.array.length; i++) { json_value *a; a = v->u.array.values[i]; if (a->type == json_object) { mastodon_log(ic, "@%s %s", json_o_str(a, "acct"), json_o_str(a, "display_name")); } } } /* statuses */ if ((v = json_o_get(parsed, "statuses")) && (v->type == json_array) && (v->u.array.length > 0)) { found = TRUE; struct mastodon_list *ml = g_new0(struct mastodon_list, 1); mastodon_xt_get_status_list(ic, v, ml); GSList *l; for (l = ml->list; l; l = g_slist_next(l)) { struct mastodon_status *s = (struct mastodon_status *) l->data; mastodon_status_show_chat(ic, s); } ml_free(ml); } json_value_free(parsed); if (!found) { mastodon_log(ic, "Search returned no results on this instance"); } } /** * Search for a status URL, account, or hashtag. */ void mastodon_search(struct im_connection *ic, char *what) { char *args[2] = { "q", what, }; mastodon_http(ic, MASTODON_SEARCH_URL, mastodon_http_search, ic, HTTP_GET, args, 2); } /** * Show information about the instance. */ void mastodon_instance(struct im_connection *ic) { mastodon_http(ic, MASTODON_INSTANCE_URL, mastodon_http_log_all, ic, HTTP_GET, NULL, 0); } /** * Show information about an account. */ void mastodon_account(struct im_connection *ic, guint64 id) { char *url = g_strdup_printf(MASTODON_ACCOUNT_URL, id); mastodon_http(ic, url, mastodon_http_log_all, ic, HTTP_GET, NULL, 0); g_free(url); } /** * Helper for all functions that need to search for an account before * they can do anything else. Provide a function to use as a callback. * This callback will get the account search result back and will need * to call mastodon_xt_get_user and do something with it. */ void mastodon_with_search_account(struct im_connection *ic, char *who, http_input_function func) { char *args[2] = { "q", who, }; mastodon_http(ic, MASTODON_ACCOUNT_SEARCH_URL, func, ic, HTTP_GET, args, 2); } /** * Show debug information for an account. */ void mastodon_search_account(struct im_connection *ic, char *who) { mastodon_with_search_account(ic, who, mastodon_http_log_all); } /** * Show debug information for the relationship with an account. */ void mastodon_relationship(struct im_connection *ic, guint64 id) { char *args[2] = { "id", g_strdup_printf("%" G_GUINT64_FORMAT, id), }; mastodon_http(ic, MASTODON_ACCOUNT_RELATIONSHIP_URL, mastodon_http_log_all, ic, HTTP_GET, args, 2); g_free(args[1]); } /** * Callback to print debug information about a relationship. */ static void mastodon_http_search_relationship(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } struct mastodon_account *ma = mastodon_xt_get_user(parsed); if (!ma) { mastodon_log(ic, "Couldn't find a matching account."); goto finish; } char *args[2] = { "id", g_strdup_printf("%" G_GUINT64_FORMAT, ma->id), }; mastodon_http(ic, MASTODON_ACCOUNT_RELATIONSHIP_URL, mastodon_http_log_all, ic, HTTP_GET, args, 2); g_free(args[1]); finish: ma_free(ma); json_value_free(parsed); } /** * Search for an account and and show debug information for the * relationship with the first account found. */ void mastodon_search_relationship(struct im_connection *ic, char *who) { mastodon_with_search_account(ic, who, mastodon_http_search_relationship); } /** * Show debug information for a status. */ void mastodon_status(struct im_connection *ic, guint64 id) { char *url = g_strdup_printf(MASTODON_STATUS_URL, id); mastodon_http(ic, url, mastodon_http_log_all, ic, HTTP_GET, NULL, 0); g_free(url); } /** * Allow the user to make a raw request. */ void mastodon_raw(struct im_connection *ic, char *method, char *url, char **arguments, int arguments_len) { http_method_t m = HTTP_GET; if (g_ascii_strcasecmp(method, "get") == 0) { m = HTTP_GET; } else if (g_ascii_strcasecmp(method, "put") == 0) { m = HTTP_PUT; } else if (g_ascii_strcasecmp(method, "post") == 0) { m = HTTP_POST; } else if (g_ascii_strcasecmp(method, "delete") == 0) { m = HTTP_DELETE; } mastodon_http(ic, url, mastodon_http_log_all, ic, m, arguments, arguments_len); } /** * Callback for showing the URL of a status. */ static void mastodon_http_status_show_url(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } struct mastodon_status *ms = mastodon_xt_get_status(parsed, ic); if (ms) { mastodon_log(ic, ms->url); ms_free(ms); } else { mastodon_log(ic, "Error: could not fetch toot url."); } json_value_free(parsed); } /** * Show the URL for a status. */ void mastodon_status_show_url(struct im_connection *ic, guint64 id) { char *url = g_strdup_printf(MASTODON_STATUS_URL, id); mastodon_http(ic, url, mastodon_http_status_show_url, ic, HTTP_GET, NULL, 0); g_free(url); } /** * Append a the acct attribute to a gstring user_data, separated with a space, if necessary. This is to be used with * g_list_foreach(). The prefix "@" is added in front of every element. */ static void mastodon_account_append(struct mastodon_account *ma, GString *user_data) { if (user_data->len > 0) { g_string_append(user_data, " "); } g_string_append(user_data, "@"); g_string_append(user_data, ma->acct); } /** * Join all the acct attributes of a list of accounts, space-separated. Be sure to free the returned GString with * g_string_free(). If there is no initial element for the list, use NULL for the second argument. The prefix "@" is * added in front of every element. It is added to the initial element, too! This is used to generated a list of * accounts to mention in a toot. */ GString *mastodon_account_join(GSList *l, gchar *init) { if (!l && !init) return NULL; GString *s = g_string_new(NULL); if (init) { g_string_append(s, "@"); g_string_append(s, init); } g_slist_foreach(l, (GFunc) mastodon_account_append, s); return s; } /** * Show the list of mentions for a status in our log data. */ void mastodon_show_mentions(struct im_connection *ic, GSList *l) { if (l) { GString *s = mastodon_account_join(l, NULL); mastodon_log(ic, "Mentioned: %s", s->str); g_string_free(s, TRUE); } else { mastodon_log(ic, "Nobody was mentioned in this toot"); } } /** * Callback for showing the mentions of a status. */ static void mastodon_http_status_show_mentions(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } struct mastodon_status *ms = mastodon_xt_get_status(parsed, ic); if (ms) { mastodon_show_mentions(ic, ms->mentions); ms_free(ms); } else { mastodon_log(ic, "Error: could not fetch toot url."); } json_value_free(parsed); } /** * Show the mentions for a status. */ void mastodon_status_show_mentions(struct im_connection *ic, guint64 id) { char *url = g_strdup_printf(MASTODON_STATUS_URL, id); mastodon_http(ic, url, mastodon_http_status_show_mentions, ic, HTTP_GET, NULL, 0); g_free(url); } /** * Attempt to flush the context data. This is called by the two * callbacks for the context request because we need to wait for two * responses: the original status details, and the context itself. */ void mastodon_flush_context(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; if (!(md->flags & MASTODON_GOT_STATUS) || !(md->flags & MASTODON_GOT_CONTEXT)) { return; } struct mastodon_status *ms = md->status_obj; struct mastodon_list *bl = md->context_before_obj; struct mastodon_list *al = md->context_after_obj; GSList *l; for (l = bl->list; l; l = g_slist_next(l)) { struct mastodon_status *s = (struct mastodon_status *) l->data; mastodon_status_show_chat(ic, s); } mastodon_status_show_chat(ic, ms); for (l = al->list; l; l = g_slist_next(l)) { struct mastodon_status *s = (struct mastodon_status *) l->data; mastodon_status_show_chat(ic, s); } ml_free(al); ml_free(bl); ms_free(ms); md->flags &= ~(MASTODON_GOT_STATUS | MASTODON_GOT_CONTEXT); md->status_obj = md->context_before_obj = md->context_after_obj = NULL; } /** * Callback for the context of a status. Store it in our mastodon data * structure and attempt to flush it. */ void mastodon_http_context(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } struct mastodon_data *md = ic->proto_data; json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; goto end; } if (parsed->type != json_object) { goto finished; } struct mastodon_list *bl = g_new0(struct mastodon_list, 1); struct mastodon_list *al = g_new0(struct mastodon_list, 1); json_value *before = json_o_get(parsed, "ancestors"); json_value *after = json_o_get(parsed, "descendants"); if (before->type == json_array && mastodon_xt_get_status_list(ic, before, bl)) { md->context_before_obj = bl; } if (after->type == json_array && mastodon_xt_get_status_list(ic, after, al)) { md->context_after_obj = al; } finished: json_value_free(parsed); end: if (ic) { md->flags |= MASTODON_GOT_CONTEXT; mastodon_flush_context(ic); } } /** * Callback for the original status as part of a context request. * Store it in our mastodon data structure and attempt to flush it. */ void mastodon_http_context_status(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } struct mastodon_data *md = ic->proto_data; json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; goto end; } md->status_obj = mastodon_xt_get_status(parsed, ic); json_value_free(parsed); end: if (ic) { md->flags |= MASTODON_GOT_STATUS; mastodon_flush_context(ic); } } /** * Search for a status and its context. The problem is that the * context doesn't include the status we're interested in. That's why * we must make two requests and wait until we get the response to * both. */ void mastodon_context(struct im_connection *ic, guint64 id) { struct mastodon_data *md = ic->proto_data; ms_free(md->status_obj); ml_free(md->context_before_obj); ml_free(md->context_after_obj); md->status_obj = md->context_before_obj = md->context_after_obj = NULL; md->flags &= ~(MASTODON_GOT_STATUS | MASTODON_GOT_CONTEXT); char *url = g_strdup_printf(MASTODON_STATUS_CONTEXT_URL, id); mastodon_http(ic, url, mastodon_http_context, ic, HTTP_GET, NULL, 0); g_free(url); url = g_strdup_printf(MASTODON_STATUS_URL, id); mastodon_http(ic, url, mastodon_http_context_status, ic, HTTP_GET, NULL, 0); g_free(url); } /** * The callback functions for mastodon_http_statuses_chain() should look like this, e.g. mastodon_account_statuses or * mastodon_account_pinned_statuses. The way this works: If you know the id of an account, call the function directly: * mastodon_account_statuses(). The callback for this is mastodon_http_statuses() which uses mastodon_http_timeline() to * display the statuses. If you don't know the id of an account by you know the "who" of the account, call a function * like mastodon_unknown_account_statuses(). It calls mastodon_with_search_account() and the callback for this is * mastodon_http_unknown_account_statuses(). The http_request it gets has the account(s) you need. Call * mastodon_chained_account() which parses the request and determines the actual account id. Finally, it calls the last * callback, which mastodon_account_statuses(), with the account id. And we already know how that works! Phew!! * */ typedef void (*mastodon_chained_account_function)(struct im_connection *ic, guint64 id); void mastodon_chained_account(struct http_request *req, mastodon_chained_account_function func) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } if (parsed->type != json_array || parsed->u.array.length == 0) { mastodon_log(ic, "Couldn't find a matching account."); goto finish; } // Just use the first one, let's hope these are sorted appropriately! struct mastodon_account *ma = mastodon_xt_get_user(parsed->u.array.values[0]); if (ma) { func(ic, ma->id); } else { mastodon_log(ic, "Couldn't find a matching account."); } ma_free(ma); finish: json_value_free(parsed); } /** * Callback for a reponse containing one or more statuses which are to be shown, usually the result of looking at the * statuses of an account. */ void mastodon_http_statuses(struct http_request *req) { mastodon_http_timeline(req, MT_HOME); } /** * Given a command that showed a bunch of statuses, which will have used mastodon_http_timeline as a callback, and which * will therefore have set md->next_url, we can use now use this URL to request more statuses. */ void mastodon_more(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; if (!md->next_url) { mastodon_log(ic, "Next URL is not set. This shouldn't happen, as they say!?"); return; } char *url = g_strdup(md->next_url); char *s = NULL; int len = 0; int i; for (i = 0; url[i]; i++) { if (url[i] == '?') { url[i] = 0; s = url + i + 1; len = 1; } else if (s && url[i] == '&') { url[i] = '='; // for later splitting len++; } } gchar **args = NULL; if (s) { args = g_strsplit (s, "=", -1); } switch(md->more_type) { case MASTODON_MORE_STATUSES: mastodon_http(ic, url, mastodon_http_statuses, ic, HTTP_GET, args, len); break; case MASTODON_MORE_NOTIFICATIONS: mastodon_http(ic, url, mastodon_http_notifications, ic, HTTP_GET, args, len); break; } g_strfreev(args); g_free(url); } /** * Show the timeline of a user. */ void mastodon_account_statuses(struct im_connection *ic, guint64 id) { char *url = g_strdup_printf(MASTODON_ACCOUNT_STATUSES_URL, id); mastodon_http(ic, url, mastodon_http_statuses, ic, HTTP_GET, NULL, 0); g_free(url); } /** * Show the pinned statuses of a user. */ void mastodon_account_pinned_statuses(struct im_connection *ic, guint64 id) { char *args[2] = { "pinned", "1", }; char *url = g_strdup_printf(MASTODON_ACCOUNT_STATUSES_URL, id); mastodon_http(ic, url, mastodon_http_statuses, ic, HTTP_GET, args, 2); g_free(url); } /** * Callback to display the timeline for a unknown user. We got the account data back and now we just take the first user * and display their timeline. */ void mastodon_http_unknown_account_statuses(struct http_request *req) { mastodon_chained_account(req, mastodon_account_statuses); } /** * Show the timeline of an unknown user. Thus, we first have to search for them. */ void mastodon_unknown_account_statuses(struct im_connection *ic, char *who) { mastodon_with_search_account(ic, who, mastodon_http_unknown_account_statuses); } /** * Callback to display the timeline for a unknown user. We got the account data back and now we just take the first user * and display their timeline. */ void mastodon_http_unknown_account_pinned_statuses(struct http_request *req) { mastodon_chained_account(req, mastodon_account_pinned_statuses); } /** * Show the timeline of an unknown user. Thus, we first have to search for them. */ void mastodon_unknown_account_pinned_statuses(struct im_connection *ic, char *who) { mastodon_with_search_account(ic, who, mastodon_http_unknown_account_pinned_statuses); } /** * Callback for the user bio. */ void mastodon_http_account_bio(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } const char *display_name = json_o_str(parsed, "display_name"); char *note = g_strdup(json_o_str(parsed, "note")); mastodon_strip_html(note); // modified in place mastodon_log(ic, "Bio for %s: %s", display_name, note); g_free(note); json_value_free(parsed); } /** * Show a user bio. */ void mastodon_account_bio(struct im_connection *ic, guint64 id) { char *url = g_strdup_printf(MASTODON_ACCOUNT_URL, id); mastodon_http(ic, url, mastodon_http_account_bio, ic, HTTP_GET, NULL, 0); g_free(url); } /** * Callback to display the timeline for a unknown user. We got the account data back and now we just take the first user * and show their bio. */ void mastodon_http_unknown_account_bio(struct http_request *req) { mastodon_chained_account(req, mastodon_account_bio); } /** * Show the bio of an unknown user. Thus, we first have to search for them. */ void mastodon_unknown_account_bio(struct im_connection *ic, char *who) { mastodon_with_search_account(ic, who, mastodon_http_unknown_account_bio); } /** * Call back for step 3 of mastodon_follow: adding the buddy. */ static void mastodon_http_follow3(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } struct mastodon_account *ma = mastodon_xt_get_user(parsed); if (ma) { mastodon_add_buddy(ic, ma->id, ma->acct, ma->display_name); mastodon_log(ic, "You are now following %s.", ma->acct); } else { mastodon_log(ic, "Couldn't find a matching account."); } ma_free(ma); json_value_free(parsed); } /** * Call back for step 2 of mastodon_follow: actually following. */ static void mastodon_http_follow2(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } json_value *it; if ((it = json_o_get(parsed, "domain_blocking")) && it->type == json_boolean && it->u.boolean) { mastodon_log(ic, "This user's domain is being blocked by your instance."); } if ((it = json_o_get(parsed, "blocking")) && it->type == json_boolean && it->u.boolean) { mastodon_log(ic, "You need to unblock this user."); } if ((it = json_o_get(parsed, "muting")) && it->type == json_boolean && it->u.boolean) { mastodon_log(ic, "You might want to unmute this user."); } if ((it = json_o_get(parsed, "muting")) && it->type == json_boolean && it->u.boolean) { mastodon_log(ic, "You might want to unmute this user."); } if ((it = json_o_get(parsed, "requested")) && it->type == json_boolean && it->u.boolean) { mastodon_log(ic, "You have requested to follow this user."); } if ((it = json_o_get(parsed, "followed_by")) && it->type == json_boolean && it->u.boolean) { mastodon_log(ic, "Nice, this user is already following you."); } if ((it = json_o_get(parsed, "following")) && it->type == json_boolean && it->u.boolean) { guint64 id; if ((it = json_o_get(parsed, "id")) && (id = mastodon_json_int64(it))) { char *url = g_strdup_printf(MASTODON_ACCOUNT_URL, id); mastodon_http(ic, url, mastodon_http_follow3, ic, HTTP_GET, NULL, 0); g_free(url); } else { mastodon_log(ic, "I can't believe it: this relation has no id. I can't add them!"); } } json_value_free(parsed); } /** * Call back for step 1 of mastodon_follow: searching for the account to follow. */ static void mastodon_http_follow1(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } if (parsed->type != json_array || parsed->u.array.length == 0) { mastodon_log(ic, "Couldn't find a matching account."); goto finish; } // Just use the first one, let's hope these are sorted appropriately! struct mastodon_account *ma = mastodon_xt_get_user(parsed->u.array.values[0]); if (ma) { char *url = g_strdup_printf(MASTODON_ACCOUNT_FOLLOW_URL, ma->id); mastodon_http(ic, url, mastodon_http_follow2, ic, HTTP_POST, NULL, 0); g_free(url); ma_free(ma); } else { mastodon_log(ic, "Couldn't find a matching account."); } finish: json_value_free(parsed); } /** * Function to follow an unknown user. First we need to search for it, * though. */ void mastodon_follow(struct im_connection *ic, char *who) { mastodon_with_search_account(ic, who, mastodon_http_follow1); } /** * Callback for adding the buddies you are following. */ static void mastodon_http_following(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } if (parsed->type != json_array || parsed->u.array.length == 0) { goto finish; } int i; for (i = 0; i < parsed->u.array.length; i++) { struct mastodon_account *ma = mastodon_xt_get_user(parsed->u.array.values[i]); if (ma) { mastodon_add_buddy(ic, ma->id, ma->acct, ma->display_name); } ma_free(ma); } finish: json_value_free(parsed); gboolean done = TRUE; // try to fetch more if there is a header saying that there is // more (URL in angled brackets) char *header = NULL; if ((header = get_rfc822_header(req->reply_headers, "Link", 0))) { char *url = NULL; char *s = NULL; int len = 0; int i; for (i = 0; header[i]; i++) { if (header[i] == '<') { url = header + i + 1; } else if (header[i] == '?') { header[i] = 0; // end url s = header + i + 1; len = 1; } else if (s && header[i] == '&') { header[i] = '='; // for later splitting len++; } else if (url && header[i] == '>') { header[i] = 0; if (strncmp(header + i + 1, "; rel=\"next\"", 12) == 0) { break; } else { url = NULL; s = NULL; len = 0; } } } if (url) { gchar **args = NULL; if (s) { args = g_strsplit (s, "=", -1); } mastodon_http(ic, url, mastodon_http_following, ic, HTTP_GET, args, len); done = FALSE; g_strfreev(args); } g_free(header); } if (done) { /* Now that we have reached the end of the list, everybody has mastodon_user_data set, at last: imcb_add_buddy → bee_user_new → ic->acc->prpl->buddy_data_add → mastodon_buddy_data_add. Now we're ready to (re)load lists. */ mastodon_list_reload(ic, TRUE); struct mastodon_data *md = ic->proto_data; md->flags |= MASTODON_HAVE_FRIENDS; } } /** * Add the buddies the current account is following. */ void mastodon_following(struct im_connection *ic) { gint64 id = set_getint(&ic->acc->set, "account_id"); if (!id) { return; } char *url = g_strdup_printf(MASTODON_ACCOUNT_FOLLOWING_URL, id); mastodon_http(ic, url, mastodon_http_following, ic, HTTP_GET, NULL, 0); g_free(url); } /** * Callback for the list of lists. */ void mastodon_http_lists(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } if (parsed->type != json_array || parsed->u.array.length == 0) { mastodon_log(ic, "Use 'list create <name>' to create a list."); goto finish; } int i; GString *s = g_string_new(g_strdup_printf("Lists: ")); gboolean first = TRUE; for (i = 0; i < parsed->u.array.length; i++) { json_value *a = parsed->u.array.values[i]; if (a->type == json_object) { if (first) { first = FALSE; } else { g_string_append(s, "; "); } g_string_append(s, json_o_str(a, "title")); } } mastodon_log(ic, s->str); g_string_free(s, TRUE); finish: json_value_free(parsed); } /** * Retrieving lists. Returns at most 50 Lists without pagination. */ void mastodon_lists(struct im_connection *ic) { mastodon_http(ic, MASTODON_LIST_URL, mastodon_http_lists, ic, HTTP_GET, NULL, 0); } /** * Create a list. */ void mastodon_list_create(struct im_connection *ic, char *title) { struct mastodon_data *md = ic->proto_data; struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; if (md->undo_type == MASTODON_NEW) { mc->command = MC_LIST_CREATE; mc->redo = g_strdup_printf("list create %s", title); mc->undo = g_strdup_printf("list delete %s", title); } char *args[2] = { "title", title, }; mastodon_http(ic, MASTODON_LIST_URL, mastodon_http_callback_and_ack, mc, HTTP_POST, args, 2); } /** * Second callback for the list of accounts. */ void mastodon_http_list_accounts2(struct http_request *req) { struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; if (!g_slist_find(mastodon_connections, ic)) { goto finally; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; goto finally; } if (parsed->type != json_array || parsed->u.array.length == 0) { mastodon_log(ic, "There are no members in this list. Your options:\n" "Delete it using 'list delete %s'\n" "Add members using 'list add <nick> to %s'", mc->str, mc->str); goto finish; } int i; GString *m = g_string_new("Members:"); for (i = 0; i < parsed->u.array.length; i++) { struct mastodon_account *ma = mastodon_xt_get_user(parsed->u.array.values[i]); if (ma) { g_string_append(m, " "); bee_user_t *bu = bee_user_by_handle(ic->bee, ic, ma->acct); if (bu) { irc_user_t *iu = bu->ui_data; g_string_append(m, iu->nick); } else { g_string_append(m, "@"); g_string_append(m, ma->acct); } ma_free(ma); } } mastodon_log(ic, m->str); g_string_free(m, TRUE); finish: /* We need to free the parsed data. */ json_value_free(parsed); finally: /* We've encountered a problem and we need to free the mastodon_command. */ mc_free(mc); } /** * Part two of the first callback: now we have mc->id. Call the URL which will give us the accounts themselves. The API * documentation says: If you specify limit=0 in the query, all accounts will be returned without pagination. */ void mastodon_list_accounts(struct im_connection *ic, struct mastodon_command *mc) { char *args[2] = { "limit", "0", }; char *url = g_strdup_printf(MASTODON_LIST_ACCOUNTS_URL, mc->id); mastodon_http(ic, url, mastodon_http_list_accounts2, mc, HTTP_GET, args, 2); g_free(url); } /** * First callback for listing the accounts in a list. First, get the list id from the data we received, then call the * next function. */ void mastodon_http_list_accounts(struct http_request *req) { mastodon_chained_list(req, mastodon_list_accounts); } /** * Show accounts in a list. */ void mastodon_unknown_list_accounts(struct im_connection *ic, char *title) { struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; mc->str = g_strdup(title); mastodon_with_named_list(ic, mc, mastodon_http_list_accounts); } /** * Second callback for list delete. We get back the accounts in the list we are about to delete. We need these to * prepare the undo command. Undo is serious business. Then we delete the list. */ void mastodon_http_list_delete2(struct http_request *req) { struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; struct mastodon_data *md = ic->proto_data; if (!g_slist_find(mastodon_connections, ic)) { goto finish; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; goto finish; } if (parsed->type != json_array || parsed->u.array.length == 0) { mastodon_log(ic, "There are no members in this list. Cool!"); } else if (md->undo_type == MASTODON_NEW) { int i; char *title = mc->str; GString *undo = g_string_new(mc->undo); for (i = 0; i < parsed->u.array.length; i++) { struct mastodon_account *ma = mastodon_xt_get_user(parsed->u.array.values[i]); if (ma) { g_string_append(undo, FS); g_string_append_printf(undo, "list add %" G_GINT64_FORMAT " to %s", ma->id, title); } ma_free(ma); } g_free(mc->undo); mc->undo = undo->str; // adopt g_string_free(undo, FALSE); } char *url = g_strdup_printf(MASTODON_LIST_DATA_URL, mc->id); mastodon_http(ic, url, mastodon_http_callback_and_ack, mc, HTTP_DELETE, NULL, 0); g_free(url); json_value_free(parsed); return; finish: /* We have encountered a problem an need to free mc. If we don't run into a problem, mc is passed on to the next * request. But not here. */ mc_free(mc); } /** * This is part of the first callback. We have the list id in mc->id! If this is a new command, we want to find all the * accounts in the list in order to prepare the undo command. Undo is serious business. If this command is not new, in * other words, this is a redo command, then we can skip right ahead and go for the list delete. */ void mastodon_list_delete(struct im_connection *ic, struct mastodon_command *mc) { struct mastodon_data *md = ic->proto_data; if (md->undo_type == MASTODON_NEW) { /* Make sure we get all the accounts for undo. The API documentation says: If you specify limit=0 in the query, * all accounts will be returned without pagination. */ char *args[2] = { "limit", "0", }; char *url = g_strdup_printf(MASTODON_LIST_ACCOUNTS_URL, mc->id); mastodon_http(ic, url, mastodon_http_list_delete2, mc, HTTP_GET, args, 2); g_free(url); } else { /* This is a short cut! */ char *url = g_strdup_printf(MASTODON_LIST_DATA_URL, mc->id); mastodon_http(ic, url, mastodon_http_callback_and_ack, mc, HTTP_DELETE, NULL, 0); g_free(url); } } /** * Callback for list delete. We get back the lists we know about and need to find the list to delete. Once we have it, * we use another callback to get the list of all its members in order to prepare the undo command. Undo is serious * business. */ void mastodon_http_list_delete(struct http_request *req) { mastodon_chained_list(req, mastodon_list_delete); } /** * Delete a list by title. */ void mastodon_unknown_list_delete(struct im_connection *ic, char *title) { struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; mc->str = g_strdup(title); struct mastodon_data *md = ic->proto_data; if (md->undo_type == MASTODON_NEW) { mc->command = MC_LIST_DELETE; mc->redo = g_strdup_printf("list delete %s", title); mc->undo = g_strdup_printf("list create %s", title); } mastodon_with_named_list(ic, mc, mastodon_http_list_delete); } /** * Part two of the first callback: now we have mc->id. Call the URL which will give us the accounts themselves. The API * documentation says: If you specify limit=0 in the query, all accounts will be returned without pagination. */ void mastodon_list_add_account(struct im_connection *ic, struct mastodon_command *mc) { char *args[2] = { "account_ids[]", g_strdup_printf("%" G_GUINT64_FORMAT, mc->id2), }; char *url = g_strdup_printf(MASTODON_LIST_ACCOUNTS_URL, mc->id); mastodon_http(ic, url, mastodon_http_callback_and_ack, mc, HTTP_POST, args, 2); g_free(args[1]); g_free(url); } /** * First callback for adding an account to a list. First, get the list id from the data we received, then call the next * function. */ void mastodon_http_list_add_account(struct http_request *req) { mastodon_chained_list(req, mastodon_list_add_account); } /** * Add one or more accounts to a list. */ void mastodon_unknown_list_add_account(struct im_connection *ic, guint64 id, char *title) { struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; mc->id2 = id; mc->str = g_strdup(title); struct mastodon_data *md = ic->proto_data; if (md->undo_type == MASTODON_NEW) { mc->command = MC_LIST_ADD_ACCOUNT; mc->redo = g_strdup_printf("list add %" G_GINT64_FORMAT " to %s", id, title); mc->undo = g_strdup_printf("list remove %" G_GINT64_FORMAT " from %s", id, title); } mastodon_with_named_list(ic, mc, mastodon_http_list_add_account); } /** * Part two of the first callback: now we have mc->id. Call the URL which will give us the accounts themselves. The API * documentation says: If you specify limit=0 in the query, all accounts will be returned without pagination. */ void mastodon_list_remove_account(struct im_connection *ic, struct mastodon_command *mc) { char *args[2] = { "account_ids[]", g_strdup_printf("%" G_GUINT64_FORMAT, mc->id2), }; char *url = g_strdup_printf(MASTODON_LIST_ACCOUNTS_URL, mc->id); mastodon_http(ic, url, mastodon_http_callback_and_ack, mc, HTTP_DELETE, args, 2); g_free(args[1]); g_free(url); } /** * First callback for removing an accounts from a list. First, get the list id from the data we received, then call the * next function. */ void mastodon_http_list_remove_account(struct http_request *req) { mastodon_chained_list(req, mastodon_list_remove_account); } /** * Remove one or more accounts from a list. */ void mastodon_unknown_list_remove_account(struct im_connection *ic, guint64 id, char *title) { struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; mc->id2 = id; mc->str = g_strdup(title); struct mastodon_data *md = ic->proto_data; if (md->undo_type == MASTODON_NEW) { mc->command = MC_LIST_REMOVE_ACCOUNT; mc->redo = g_strdup_printf("list remove %" G_GINT64_FORMAT " from %s", id, title); mc->undo = g_strdup_printf("list add %" G_GINT64_FORMAT " to %s", id, title); } mastodon_with_named_list(ic, mc, mastodon_http_list_remove_account); } /** * Second callback to reload all the lists. We are getting all the accounts for one of the lists, here. * The mastodon_command (mc) has id (id of the list), str (title of the list), and optionally extra. */ static void mastodon_http_list_reload2(struct http_request *req) { struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; if (!g_slist_find(mastodon_connections, ic)) { goto finally; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; goto finally; } if (parsed->type != json_array || parsed->u.array.length == 0) { goto finish; } int i; for (i = 0; i < parsed->u.array.length; i++) { struct mastodon_account *ma; bee_user_t *bu; struct mastodon_user_data *mud; if ((ma = mastodon_xt_get_user(parsed->u.array.values[i])) && (bu = bee_user_by_handle(ic->bee, ic, ma->acct)) && (mud = (struct mastodon_user_data*) bu->data)) { mud->lists = g_slist_prepend(mud->lists, g_strdup(mc->str)); ma_free(ma); } } mastodon_log(ic, "Membership of %s list reloaded", mc->str); finish: json_value_free(parsed); if (mc->extra) { /* Keep using the mc and don't free it! */ mastodon_list_timeline(ic, mc); return; } finally: /* We've encountered a problem and we need to free the mastodon_command. */ mc_free(mc); } /** * First callback to reload all the lists. We are getting all the lists, here. For each one, get the members in the * list. Our mastodon_command (mc) might have the extra attribute set. */ static void mastodon_http_list_reload(struct http_request *req) { struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; if (!g_slist_find(mastodon_connections, ic)) { goto finally; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; goto finally; } if (parsed->type != json_array || parsed->u.array.length == 0) { goto finish; } /* Clear existing list membership. */ GSList *l; for (l = ic->bee->users; l; l = l->next) { bee_user_t *bu = l->data; struct mastodon_user_data *mud = (struct mastodon_user_data*) bu->data; if (mud) { g_slist_free_full(mud->lists, g_free); mud->lists = NULL; } } int i; guint64 id = 0; /* Get members for every list defined. */ for (i = 0; i < parsed->u.array.length; i++) { json_value *a = parsed->u.array.values[i]; json_value *it; const char *title; if (a->type == json_object && (it = json_o_get(a, "id")) && (id = mastodon_json_int64(it)) && (title = json_o_str(a, "title"))) { struct mastodon_command *mc2 = g_new0(struct mastodon_command, 1); mc2->ic = ic; mc2->id = id; mc2->str = g_strdup(title); mc2->extra = mc->extra; char *url = g_strdup_printf(MASTODON_LIST_ACCOUNTS_URL, id); mastodon_http(ic, url, mastodon_http_list_reload2, mc2, HTTP_GET, NULL, 0); g_free(url); } } finish: json_value_free(parsed); finally: /* We've encountered a problem and we need to free the mastodon_command. */ mc_free(mc); } /** * Reload the memberships of all the lists. We need this for mastodon_status_show_chat(). The populate parameter says * whether we should issue a timeline request for every list we have a group chat for, at the end. */ void mastodon_list_reload(struct im_connection *ic, gboolean populate) { struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; mc->extra = populate; mastodon_http(ic, MASTODON_LIST_URL, mastodon_http_list_reload, mc, HTTP_GET, NULL, 0); } /** * Free the md->filter list so that it can be done from mastodon_logout(). */ void mastodon_filters_destroy(struct mastodon_data *md) { GSList *l; for (l = md->filters; l; l = g_slist_next(l)) { mf_free((struct mastodon_filter *) l->data); } g_slist_free(md->filters); md->filters = NULL; } /** * Parse the context attribute of a Mastodon filter. */ mastodon_filter_type_t mastodon_parse_context(json_value *parsed) { mastodon_filter_type_t context = 0; int i; for (i = 0; i < parsed->u.array.length; i++) { json_value *s = parsed->u.array.values[i]; if (s->type == json_string) { if (g_ascii_strcasecmp(s->u.string.ptr, "home") == 0) context |= MF_HOME; if (g_ascii_strcasecmp(s->u.string.ptr, "notifications") == 0) context |= MF_NOTIFICATIONS; if (g_ascii_strcasecmp(s->u.string.ptr, "public") == 0) context |= MF_PUBLIC; if (g_ascii_strcasecmp(s->u.string.ptr, "thread") == 0) context |= MF_THREAD; } } return context; } /** * Parse a filter. */ struct mastodon_filter *mastodon_parse_filter (json_value *parsed) { json_value *it; guint64 id = 0; const char *phrase; if (parsed && parsed->type == json_object && (it = json_o_get(parsed, "id")) && (id = mastodon_json_int64(it)) && (phrase = json_o_str(parsed, "phrase"))) { struct mastodon_filter *mf = g_new0(struct mastodon_filter, 1); mf->id = id; mf->phrase = g_strdup(phrase); mf->phrase_case_folded = g_utf8_casefold(phrase, -1); if ((it = json_o_get(parsed, "context")) && it->type == json_array) mf->context = mastodon_parse_context(it); if ((it = json_o_get(parsed, "irreversible")) && it->type == json_boolean) mf->irreversible = it->u.boolean; if ((it = json_o_get(parsed, "whole_word")) && it->type == json_boolean) mf->whole_word = it->u.boolean; struct tm time; if ((it = json_o_get(parsed, "expires_in")) && it->type == json_string && strptime(it->u.string.ptr, MASTODON_TIME_FORMAT, &time) != NULL) mf->expires_in = mktime_utc(&time); return mf; } return NULL; } /** * Callback for loading filters. We need to do this when connecting to the instance, and we want to do it when * displaying the filters. */ void mastodon_http_filters_load (struct http_request *req) { struct im_connection *ic = req->data; struct mastodon_data *md = ic->proto_data; if (!g_slist_find(mastodon_connections, ic)) { return; } if (req->status_code != 200) { mastodon_log(ic, "Filters did not load. This requires Mastodon v2.4.3 or newer. See 'info instance' for more about your instance."); // Don't log off: no ic = NULL! return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } if (parsed->type != json_array || parsed->u.array.length == 0) { goto finish; } mastodon_filters_destroy(md); int i; for (i = 0; i < parsed->u.array.length; i++) { json_value *it = parsed->u.array.values[i]; struct mastodon_filter *mf = mastodon_parse_filter(it); if (mf) md->filters = g_slist_prepend(md->filters, mf); } finish: json_value_free(parsed); } /** * Callback for reloading and displaying filters. */ void mastodon_http_filters (struct http_request *req) { struct im_connection *ic = req->data; struct mastodon_data *md = ic->proto_data; mastodon_http_filters_load(req); if (!md->filters) { mastodon_log(ic, "No filters. Use 'filter create'."); return; } GSList *l; int i = 1; for (l = md->filters; l; l = g_slist_next(l)) { struct mastodon_filter *mf = (struct mastodon_filter *) l->data; GString *p = g_string_new(NULL); int mask = MF_HOME|MF_PUBLIC|MF_NOTIFICATIONS|MF_THREAD; if ((mf->context & mask) == mask) { g_string_append(p, " everywhere"); } else { if (mf->context & MF_HOME) { g_string_append(p, " home"); } if (mf->context & MF_PUBLIC) { g_string_append(p, " public"); } if (mf->context & MF_NOTIFICATIONS) { g_string_append(p, " notifications"); } if (mf->context & MF_THREAD) { g_string_append(p, " thread"); } } if (mf->irreversible) { g_string_append(p, ", server side"); } if (mf->whole_word) { g_string_append(p, ", whole word"); } mastodon_log(ic, "%2d. %s (properties:%s)", i++, mf->phrase, p->str); g_string_free(p, TRUE); } } /** * Load and display the filters from the instance. */ void mastodon_filters(struct im_connection *ic) { mastodon_http(ic, MASTODON_FILTER_URL, mastodon_http_filters, ic, HTTP_GET, NULL, 0); } /** * Callback for mastodon_get_filters. */ void mastodon_http_get_filters (struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } mastodon_http_filters_load(req); struct mastodon_data *md = ic->proto_data; md->flags |= MASTODON_GOT_FILTERS; mastodon_flush_timeline(ic); } /** * See mastodon_initial_timeline. */ static void mastodon_get_filters(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; md->flags &= ~MASTODON_GOT_FILTERS; mastodon_http(ic, MASTODON_FILTER_URL, mastodon_http_get_filters, ic, HTTP_GET, NULL, 0); } /** * Callback for filter creation. We need to get the number of the filter created and use that for the undo command. */ void mastodon_http_filter_create(struct http_request *req) { struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if (!(parsed = mastodon_parse_response(ic, req))) { /* ic would have been freed in imc_logout in this situation */ ic = NULL; return; } struct mastodon_filter *mf = mastodon_parse_filter(parsed); if (mf) { struct mastodon_data *md = ic->proto_data; md->filters = g_slist_prepend(md->filters, mf); mastodon_log(ic, "Filter created"); /* Maintain undo/redo list. */ mc->undo = g_strdup_printf("filter delete %" G_GUINT64_FORMAT, mf->id); if(md->undo_type == MASTODON_NEW) { mastodon_do(ic, mc->redo, mc->undo); } else { mastodon_do_update(ic, mc->undo); } } } /** * Create a new filter. */ void mastodon_filter_create(struct im_connection *ic, char *str) { struct mastodon_data *md = ic->proto_data; struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; if (md->undo_type == MASTODON_NEW) { mc->command = MC_FILTER_CREATE; mc->redo = g_strdup_printf("filter create %s", str); } /* FIXME: add timeout */ char *args[14] = { "phrase", str, "context[]", "home", "context[]", "notifications", "context[]", "public", "context[]", "thread", "irreversible", "true", "whole_words", "1", }; mastodon_http(ic, MASTODON_FILTER_URL, mastodon_http_filter_create, mc, HTTP_POST, args, 14); } /** * Callback for filter deletion. */ void mastodon_http_filter_delete(struct http_request *req) { struct mastodon_command *mc = req->data; struct im_connection *ic = mc->ic; if (!g_slist_find(mastodon_connections, ic)) { return; } if (req->status_code == 200) { struct mastodon_data *md = ic->proto_data; struct mastodon_filter *mf = (struct mastodon_filter *) mc->data; md->filters = g_slist_remove(md->filters, mf); mastodon_http_callback_and_ack(req); } } /** * Delete a filter based on the item number when listing them. */ void mastodon_filter_delete(struct im_connection *ic, char *arg) { guint64 id; if (!parse_int64(arg, 10, &id)) { mastodon_log(ic, "You must refer to a filter number. Use 'filter' to list them."); return; } struct mastodon_data *md = ic->proto_data; /* filters are listed starting at 1 */ struct mastodon_filter *mf = (struct mastodon_filter *) g_slist_nth_data(md->filters, id - 1); if (!mf) { GSList *l; gboolean found = FALSE; for (l = md->filters; l; l = g_slist_next(l)) { mf = (struct mastodon_filter *) l->data; if (mf->id == id) { found = TRUE; break; } } if (!found) { mastodon_log(ic, "This filter is unkown. Use 'filter' to list them."); return; } } struct mastodon_command *mc = g_new0(struct mastodon_command, 1); mc->ic = ic; mc->data = (gpointer *) mf; if (md->undo_type == MASTODON_NEW) { mc->command = MC_FILTER_DELETE; /* FIXME: more parameters */ mc->redo = g_strdup_printf("filter delete %" G_GUINT64_FORMAT, mf->id); mc->undo = g_strdup_printf("filter create %s", mf->phrase); } char *url = g_strdup_printf(MASTODON_FILTER_DATA_URL, mf->id); mastodon_http(ic, url, mastodon_http_filter_delete, mc, HTTP_DELETE, NULL, 0); g_free(url); } /** * Callback for getting your own account. This saves the account_id. * Once we have that, we are ready to figure out who our followers are. */ static void mastodon_http_verify_credentials(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } json_value *parsed; if ((parsed = mastodon_parse_response(ic, req))) { json_value *it; guint64 id; if ((it = json_o_get(parsed, "id")) && (id = mastodon_json_int64(it))) { set_setint(&ic->acc->set, "account_id", id); } json_value_free(parsed); mastodon_following(ic); } } /** * Get the account of the current user. */ void mastodon_verify_credentials(struct im_connection *ic) { imcb_log(ic, "Verifying credentials"); mastodon_http(ic, MASTODON_VERIFY_CREDENTIALS_URL, mastodon_http_verify_credentials, ic, HTTP_GET, NULL, 0); } /** * Callback for registering a new application. */ static void mastodon_http_register_app(struct http_request *req) { struct im_connection *ic = req->data; if (!g_slist_find(mastodon_connections, ic)) { return; } mastodon_log(ic, "Parsing application registration response"); json_value *parsed; if ((parsed = mastodon_parse_response(ic, req))) { set_setint(&ic->acc->set, "app_id", json_o_get(parsed, "id")->u.integer); char *key = json_o_strdup(parsed, "client_id"); char *secret = json_o_strdup(parsed, "client_secret"); json_value_free(parsed); // save for future sessions set_setstr(&ic->acc->set, "consumer_key", key); set_setstr(&ic->acc->set, "consumer_secret", secret); // and set for the current session, and connect struct mastodon_data *md = ic->proto_data; struct oauth2_service *os = md->oauth2_service; os->consumer_key = key; os->consumer_secret = secret; oauth2_init(ic); } } /** * Function to register a new application (Bitlbee) for the server. */ void mastodon_register_app(struct im_connection *ic) { char *args[8] = { "client_name", "bitlbee", "redirect_uris", "urn:ietf:wg:oauth:2.0:oob", "scopes", "read write follow", "website", "https://www.bitlbee.org/" }; mastodon_http(ic, MASTODON_REGISTER_APP_URL, mastodon_http_register_app, ic, HTTP_POST, args, 8); } �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/src/mastodon-lib.h�����������������������������������������������������������0000664�0000000�0000000�00000022163�13634420343�0020306�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������/***************************************************************************\ * * * BitlBee - An IRC to IM gateway * * Simple module to facilitate Mastodon functionality. * * * * Copyright 2009 Geert Mulders <g.c.w.m.mulders@gmail.com> * * Copyright 2017-2018 Alex Schroeder <alex@gnu.org> * * * * This library is free software; you can redistribute it and/or * * modify it under the terms of the GNU Lesser General Public * * License as published by the Free Software Foundation, version * * 2.1. * * * * This library is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * * Lesser General Public License for more details. * * * * You should have received a copy of the GNU Lesser General Public License * * along with this library; if not, write to the Free Software Foundation, * * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA * * * ****************************************************************************/ #pragma once #include "nogaim.h" #include "mastodon-http.h" #define MASTODON_DEFAULT_INSTANCE "https://octodon.social" // "2017-08-02T10:45:03.000Z" -- but we're ignoring microseconds and UTC timezone #define MASTODON_TIME_FORMAT "%Y-%m-%dT%H:%M:%S" #define MASTODON_API(version) "/api/v" #version #define MASTODON_REGISTER_APP_URL MASTODON_API(1) "/apps" #define MASTODON_VERIFY_CREDENTIALS_URL MASTODON_API(1) "/accounts/verify_credentials" #define MASTODON_STREAMING_USER_URL MASTODON_API(1) "/streaming/user" #define MASTODON_STREAMING_HASHTAG_URL MASTODON_API(1) "/streaming/hashtag" #define MASTODON_STREAMING_LOCAL_URL MASTODON_API(1) "/streaming/public/local" #define MASTODON_STREAMING_FEDERATED_URL MASTODON_API(1) "/streaming/public" #define MASTODON_STREAMING_LIST_URL MASTODON_API(1) "/streaming/list" #define MASTODON_HOME_TIMELINE_URL MASTODON_API(1) "/timelines/home" #define MASTODON_PUBLIC_TIMELINE_URL MASTODON_API(1) "/timelines/public" #define MASTODON_HASHTAG_TIMELINE_URL MASTODON_API(1) "/timelines/tag/%s" #define MASTODON_LIST_TIMELINE_URL MASTODON_API(1) "/timelines/list/%" G_GINT64_FORMAT #define MASTODON_NOTIFICATIONS_URL MASTODON_API(1) "/notifications" #define MASTODON_REPORT_URL MASTODON_API(1) "/reports" #define MASTODON_SEARCH_URL MASTODON_API(2) "/search" #define MASTODON_INSTANCE_URL MASTODON_API(1) "/instance" #define MASTODON_ID_FORMAT(prefix,suffix) MASTODON_API(1) "/" prefix "/%" G_GINT64_FORMAT suffix #define MASTODON_STATUS_FORMAT(suffix) MASTODON_ID_FORMAT("statuses",suffix) #define MASTODON_STATUS_POST_URL MASTODON_API(1) "/statuses" #define MASTODON_STATUS_URL MASTODON_STATUS_FORMAT("") #define MASTODON_STATUS_BOOST_URL MASTODON_STATUS_FORMAT("/reblog") #define MASTODON_STATUS_UNBOOST_URL MASTODON_STATUS_FORMAT("/unreblog") #define MASTODON_STATUS_MUTE_URL MASTODON_STATUS_FORMAT("/mute") #define MASTODON_STATUS_UNMUTE_URL MASTODON_STATUS_FORMAT("/unmute") #define MASTODON_STATUS_FAVOURITE_URL MASTODON_STATUS_FORMAT("/favourite") #define MASTODON_STATUS_UNFAVOURITE_URL MASTODON_STATUS_FORMAT("/unfavourite") #define MASTODON_STATUS_PIN_URL MASTODON_STATUS_FORMAT("/pin") #define MASTODON_STATUS_UNPIN_URL MASTODON_STATUS_FORMAT("/unpin") #define MASTODON_STATUS_CONTEXT_URL MASTODON_STATUS_FORMAT("/context") #define MASTODON_ACCOUNT_FORMAT(suffix) MASTODON_ID_FORMAT("accounts",suffix) #define MASTODON_ACCOUNT_URL MASTODON_ACCOUNT_FORMAT("") #define MASTODON_ACCOUNT_SEARCH_URL MASTODON_ACCOUNT_FORMAT("/search") #define MASTODON_ACCOUNT_STATUSES_URL MASTODON_ACCOUNT_FORMAT("/statuses") #define MASTODON_ACCOUNT_FOLLOWING_URL MASTODON_ACCOUNT_FORMAT("/following") #define MASTODON_ACCOUNT_BLOCK_URL MASTODON_ACCOUNT_FORMAT("/block") #define MASTODON_ACCOUNT_UNBLOCK_URL MASTODON_ACCOUNT_FORMAT("/unblock") #define MASTODON_ACCOUNT_FOLLOW_URL MASTODON_ACCOUNT_FORMAT("/follow") #define MASTODON_ACCOUNT_UNFOLLOW_URL MASTODON_ACCOUNT_FORMAT("/unfollow") #define MASTODON_ACCOUNT_MUTE_URL MASTODON_ACCOUNT_FORMAT("/mute") #define MASTODON_ACCOUNT_UNMUTE_URL MASTODON_ACCOUNT_FORMAT("/unmute") #define MASTODON_LIST_URL MASTODON_API(1) "/lists" #define MASTODON_LIST_FORMAT(suffix) MASTODON_ID_FORMAT("lists",suffix) #define MASTODON_LIST_DATA_URL MASTODON_LIST_FORMAT("") #define MASTODON_LIST_ACCOUNTS_URL MASTODON_LIST_FORMAT("/accounts") #define MASTODON_FILTER_URL MASTODON_API(1) "/filters" #define MASTODON_FILTER_DATA_URL MASTODON_API(1) "/filters/%" G_GINT64_FORMAT #define MASTODON_ACCOUNT_RELATIONSHIP_URL MASTODON_API(1) "/accounts/relationships" typedef enum { MASTODON_EVT_UNKNOWN, MASTODON_EVT_UPDATE, MASTODON_EVT_NOTIFICATION, MASTODON_EVT_DELETE, } mastodon_evt_flags_t; void mastodon_register_app(struct im_connection *ic); void mastodon_verify_credentials(struct im_connection *ic); void mastodon_notifications(struct im_connection *ic); void mastodon_initial_timeline(struct im_connection *ic); void mastodon_hashtag_timeline(struct im_connection *ic, char *hashtag); void mastodon_home_timeline(struct im_connection *ic); void mastodon_local_timeline(struct im_connection *ic); void mastodon_federated_timeline(struct im_connection *ic); void mastodon_open_user_stream(struct im_connection *ic); void mastodon_unknown_list_timeline(struct im_connection *ic, char *title); struct http_request *mastodon_open_hashtag_stream(struct im_connection *ic, char *hashtag); struct http_request *mastodon_open_local_stream(struct im_connection *ic); struct http_request *mastodon_open_federated_stream(struct im_connection *ic); void mastodon_open_unknown_list_stream(struct im_connection *ic, struct groupchat *c, char *title); mastodon_visibility_t mastodon_default_visibility(struct im_connection *ic); mastodon_visibility_t mastodon_parse_visibility(char *value); char *mastodon_visibility(mastodon_visibility_t visibility); void mastodon_post_status(struct im_connection *ic, char *msg, guint64 in_reply_to, mastodon_visibility_t visibility, char *spoiler_text); void mastodon_post(struct im_connection *ic, char *format, mastodon_command_type_t command, guint64 id); GString *mastodon_account_join(GSList *l, gchar *init); void mastodon_show_mentions(struct im_connection *ic, GSList *l); void mastodon_status_show_mentions(struct im_connection *ic, guint64 id); void mastodon_status_show_url(struct im_connection *ic, guint64 id); void mastodon_report(struct im_connection *ic, guint64 id, char *comment); void mastodon_follow(struct im_connection *ic, char *who); void mastodon_status_delete(struct im_connection *ic, guint64 id); void mastodon_instance(struct im_connection *ic); void mastodon_account(struct im_connection *ic, guint64 id); void mastodon_search_account(struct im_connection *ic, char *who); void mastodon_status(struct im_connection *ic, guint64 id); void mastodon_raw(struct im_connection *ic, char *method, char *url, char **arguments, int arguments_len); void mastodon_relationship(struct im_connection *ic, guint64 id); void mastodon_search_relationship(struct im_connection *ic, char *who); void mastodon_search(struct im_connection *ic, char *what); void mastodon_context(struct im_connection *ic, guint64 id); void mastodon_more(struct im_connection *ic); void mastodon_account_statuses(struct im_connection *ic, guint64 id); void mastodon_unknown_account_statuses(struct im_connection *ic, char *who); void mastodon_account_pinned_statuses(struct im_connection *ic, guint64 id); void mastodon_unknown_account_pinned_statuses(struct im_connection *ic, char *who); void mastodon_account_bio(struct im_connection *ic, guint64 id); void mastodon_unknown_account_bio(struct im_connection *ic, char *who); void mastodon_lists(struct im_connection *ic); void mastodon_list_create(struct im_connection *ic, char *title); void mastodon_unknown_list_accounts(struct im_connection *ic, char *title); void mastodon_unknown_list_delete(struct im_connection *ic, char *title); void mastodon_unknown_list_add_account(struct im_connection *ic, guint64 id, char *title); void mastodon_unknown_list_remove_account(struct im_connection *ic, guint64 id, char *title); void mastodon_list_reload(struct im_connection *ic, gboolean populate); void mastodon_filters_destroy(struct mastodon_data *md); void mastodon_filters(struct im_connection *ic); void mastodon_filter_create(struct im_connection *ic, char *str); void mastodon_filter_delete(struct im_connection *ic, char *arg); �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/src/mastodon.c���������������������������������������������������������������0000664�0000000�0000000�00000160433�13634420343�0017540�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������/***************************************************************************\ * * * BitlBee - An IRC to IM gateway * * Simple module to facilitate Mastodon functionality. * * * * Copyright 2009-2010 Geert Mulders <g.c.w.m.mulders@gmail.com> * * Copyright 2010-2013 Wilmer van der Gaast <wilmer@gaast.net> * * Copyright 2017-2019 Alex Schroeder <alex@gnu.org> * * * * This library is free software; you can redistribute it and/or * * modify it under the terms of the GNU Lesser General Public * * License as published by the Free Software Foundation, version * * 2.1. * * * * This library is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * * Lesser General Public License for more details. * * * * You should have received a copy of the GNU Lesser General Public License * * along with this library; if not, write to the Free Software Foundation, * * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA * * * ****************************************************************************/ #include "bitlbee.h" #include "account.h" #include "nogaim.h" #include "oauth.h" #include "oauth2.h" #include "mastodon.h" #include "mastodon-http.h" #include "mastodon-lib.h" #include "rot13.h" #include "url.h" #include "help.h" #include <stdbool.h> #define HELPFILE_NAME "mastodon-help.txt" static void mastodon_help_init() { /* Figure out where our help file is by looking at the global helpfile. */ gchar *dir = g_path_get_dirname (global.helpfile); if (strcmp(dir, ".") == 0) { log_message(LOGLVL_WARNING, "Error finding the directory of helpfile %s.", global.helpfile); g_free(dir); return; } gchar *df = g_strjoin("/", dir, HELPFILE_NAME, NULL); g_free(dir); /* Load help from our own help file. */ help_t *dh; help_init(&dh, df); if(dh == NULL) { log_message(LOGLVL_WARNING, "Error opening helpfile: %s.", df); g_free(df); return; } g_free(df); /* Link the last entry of global.help with first entry of our help. */ help_t *h, *l = NULL; for (h = global.help; h; h = h->next) { l = h; } if (l) { l->next = dh; } else { /* No global help but ours? */ global.help = dh; } } #ifdef BITLBEE_ABI_VERSION_CODE struct plugin_info *init_plugin_info(void) { /* Run ./configure to change these. */ static struct plugin_info info = { BITLBEE_ABI_VERSION_CODE, PACKAGE_NAME, PACKAGE_VERSION, "Bitlbee plugin for Mastodon <https://joinmastodon.org/>", "Alex Schroeder <alex@gnu.org>", "https://alexschroeder.ch/cgit/bitlbee-mastodon/about/" }; return &info; } #endif GSList *mastodon_connections = NULL; struct groupchat *mastodon_groupchat_init(struct im_connection *ic) { struct groupchat *gc; struct mastodon_data *md = ic->proto_data; GSList *l; if (md->timeline_gc) { return md->timeline_gc; } md->timeline_gc = gc = imcb_chat_new(ic, "mastodon/timeline"); imcb_chat_name_hint(gc, md->name); for (l = ic->bee->users; l; l = l->next) { bee_user_t *bu = l->data; if (bu->ic == ic) { imcb_chat_add_buddy(gc, bu->handle); } } imcb_chat_add_buddy(gc, ic->acc->user); return gc; } /** * Free the oauth2_service struct. */ static void os_free(struct oauth2_service *os) { if (os == NULL) { return; } g_free(os->auth_url); g_free(os->token_url); g_free(os); } /** * Create a new oauth2_service struct. If we haven never connected to * the server, we'll be missing our key and secret. */ static struct oauth2_service *get_oauth2_service(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; struct oauth2_service *os = g_new0(struct oauth2_service, 1); os->auth_url = g_strconcat("https://", md->url_host, "/oauth/authorize", NULL); os->token_url = g_strconcat("https://", md->url_host, "/oauth/token", NULL); os->redirect_url = "urn:ietf:wg:oauth:2.0:oob"; os->scope = MASTODON_SCOPE; // possibly empty strings if the client is not registered os->consumer_key = set_getstr(&ic->acc->set, "consumer_key"); os->consumer_secret = set_getstr(&ic->acc->set, "consumer_secret"); return os; } /** * Check message length by comparing it to the appropriate setting. * Note this issue: "Count all URLs in text as 23 characters flat, do * not count domain part of usernames." * https://github.com/tootsuite/mastodon/pull/4427 **/ static gboolean mastodon_length_check(struct im_connection *ic, gchar *msg, char *cw) { int len = g_utf8_strlen(msg, -1); if (len == 0) { mastodon_log(ic, "This message is empty."); return FALSE; } if (cw != NULL) { len += g_utf8_strlen(cw, -1); } int max = set_getint(&ic->acc->set, "message_length"); if (max == 0) { return TRUE; } GRegex *regex = g_regex_new (MASTODON_URL_REGEX, 0, 0, NULL); GMatchInfo *match_info; g_regex_match (regex, msg, 0, &match_info); while (g_match_info_matches (match_info)) { gchar *url = g_match_info_fetch (match_info, 0); len = len - g_utf8_strlen(url, -1) + 23; g_free (url); g_match_info_next (match_info, NULL); } g_regex_unref (regex); regex = g_regex_new (MASTODON_MENTION_REGEX, 0, 0, NULL); g_regex_match (regex, msg, 0, &match_info); while (g_match_info_matches (match_info)) { gchar *mention = g_match_info_fetch (match_info, 0); gchar *nick = g_match_info_fetch (match_info, 2); len = len - g_utf8_strlen(mention, -1) + g_utf8_strlen(nick, -1); g_free (mention); g_free (nick); g_match_info_next (match_info, NULL); } g_regex_unref (regex); g_match_info_free (match_info); if (len <= max) { return TRUE; } mastodon_log(ic, "Maximum message length exceeded: %d > %d", len, max); return FALSE; } static char *set_eval_commands(set_t * set, char *value) { if (g_ascii_strcasecmp(value, "strict") == 0) { return value; } else { return set_eval_bool(set, value); } } static char *set_eval_mode(set_t * set, char *value) { if (g_ascii_strcasecmp(value, "one") == 0 || g_ascii_strcasecmp(value, "many") == 0 || g_ascii_strcasecmp(value, "chat") == 0) { return value; } else { return NULL; } } static char *set_eval_hide_sensitive(set_t * set, char *value) { if (g_ascii_strcasecmp(value, "rot13") == 0 || g_ascii_strcasecmp(value, "advanced_rot13") == 0) { return value; } else { return set_eval_bool(set, value); } } static char *set_eval_visibility(set_t * set, char *value) { if (g_ascii_strcasecmp(value, "public") == 0 || g_ascii_strcasecmp(value, "unlisted") == 0 || g_ascii_strcasecmp(value, "private") == 0) { return value; } else { return "public"; } } static void mastodon_init(account_t * acc) { set_t *s; char* handle = acc -> user, * new_user_name; bool change_user_name = false; if (*handle == '@') { change_user_name = true; new_user_name = ++ handle; } else new_user_name = acc -> user; size_t handle_sz = strlen(handle); char const* base_url; while (*handle != '@') { if (*handle == 0) { /* the user has entered an invalid handle - the smart thing * to do here would be to fail, but bitlbee doesn't provide * a way for us to indicate that an account add command has * failed, so we glue a common instance name to the account * and hope for the best */ base_url = MASTODON_DEFAULT_INSTANCE; goto no_instance_in_username; } handle++; } *handle = 0; /* delete the server component from the handle */ change_user_name = true; size_t endpoint_sz = (handle - (acc -> user)); handle_sz -= endpoint_sz + 1; /* construct a server url */ { char const* instance = handle + 1; char* endpoint = alloca( /* using alloca instead of VLAs to avoid thorny scope problems */ endpoint_sz + sizeof "https://" + 1 /* trailing nul */ ); char* eptr = endpoint; eptr = g_stpcpy(eptr, "https://"); eptr = g_stpcpy(eptr, instance); base_url = endpoint; } no_instance_in_username: if (change_user_name) { char saved_str [handle_sz + 1]; g_stpcpy(saved_str, new_user_name); /* i promise i can explain. * i haven't dug too deeply into what causes this bug, because * it's 5am and i've gotten no sleep tonight, but for some * ungodly reason - due to a bug in either glib or the bitlbee * set structure - passing a substring of the set's existing * value appears to cause memory corruption of some kind (in * this instance, deleting the first character of the username.) * temporarily duplicating the string and setting it from the * duplicate seems to fix the problem. it's an atrocious hack, * and if you're reading this, i beg you to do what i did not * have the strength to, and figure out why on god's green * earth it happened. */ set_setstr(&acc -> set, "username", saved_str); } s = set_add(&acc->set, "auto_reply_timeout", "10800", set_eval_int, acc); s = set_add(&acc->set, "base_url", base_url, NULL, acc); s->flags |= ACC_SET_OFFLINE_ONLY; s = set_add(&acc->set, "commands", "true", set_eval_commands, acc); s = set_add(&acc->set, "message_length", "500", set_eval_int, acc); s = set_add(&acc->set, "mode", "chat", set_eval_mode, acc); s->flags |= ACC_SET_OFFLINE_ONLY; s = set_add(&acc->set, "name", "", NULL, acc); s->flags |= ACC_SET_OFFLINE_ONLY; s = set_add(&acc->set, "show_ids", "true", set_eval_bool, acc); s = set_add(&acc->set, "strip_newlines", "false", set_eval_bool, acc); s = set_add(&acc->set, "hide_sensitive", "false", set_eval_hide_sensitive, acc); s = set_add(&acc->set, "sensitive_flag", "*NSFW* ", NULL, acc); s = set_add(&acc->set, "visibility", "public", set_eval_visibility, acc); s = set_add(&acc->set, "hide_boosts", "false", set_eval_bool, acc); s = set_add(&acc->set, "hide_favourites", "false", set_eval_bool, acc); s = set_add(&acc->set, "hide_mentions", "false", set_eval_bool, acc); s = set_add(&acc->set, "hide_follows", "false", set_eval_bool, acc); s = set_add(&acc->set, "app_id", "0", set_eval_int, acc); s->flags |= SET_HIDDEN; s = set_add(&acc->set, "account_id", "0", set_eval_int, acc); s->flags |= SET_HIDDEN; s = set_add(&acc->set, "consumer_key", "", NULL, acc); s->flags |= SET_HIDDEN; s = set_add(&acc->set, "consumer_secret", "", NULL, acc); s->flags |= SET_HIDDEN; mastodon_help_init(); } /** * Set the name of the Mastodon channel, either based on a preference, or based on hostname and account name. */ static void mastodon_set_name(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; char *name = set_getstr(&ic->acc->set, "name"); if (name[0]) { md->name = g_strdup(name); } else { md->name = g_strdup_printf("%s_%s", md->url_host, ic->acc->user); } } /** * Connect to Mastodon server, using the data we saved in the account. */ static void mastodon_connect(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; url_t url; char *s; imcb_log(ic, "Connecting"); if (!url_set(&url, set_getstr(&ic->acc->set, "base_url")) || url.proto != PROTO_HTTPS) { imcb_error(ic, "Incorrect API base URL: %s", set_getstr(&ic->acc->set, "base_url")); imc_logout(ic, FALSE); return; } md->url_ssl = url.proto == PROTO_HTTPS; // always md->url_port = url.port; md->url_host = g_strdup(url.host); mastodon_set_name(ic); imcb_add_buddy(ic, md->name, NULL); imcb_buddy_status(ic, md->name, OPT_LOGGED_IN, NULL, NULL); md->log = g_new0(struct mastodon_log_data, MASTODON_LOG_LENGTH); md->log_id = -1; s = set_getstr(&ic->acc->set, "mode"); if (g_ascii_strcasecmp(s, "one") == 0) { md->flags |= MASTODON_MODE_ONE; } else if (g_ascii_strcasecmp(s, "many") == 0) { md->flags |= MASTODON_MODE_MANY; } else { md->flags |= MASTODON_MODE_CHAT; } if (!(md->flags & MASTODON_MODE_ONE) && !(md->flags & MASTODON_HAVE_FRIENDS)) { // find our account_id and store it, eventually mastodon_verify_credentials(ic); } /* Create the room. */ if (md->flags & MASTODON_MODE_CHAT) { mastodon_groupchat_init(ic); } mastodon_initial_timeline(ic); mastodon_open_user_stream(ic); ic->flags |= OPT_PONGS; } /** * Initiate OAuth dialog with user. A reply to the MASTODON_OAUTH_HANDLE is handled by mastodon_buddy_msg. */ void oauth2_init(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; imcb_log(ic, "Starting OAuth authentication"); /* Temporary contact, just used to receive the OAuth response. */ imcb_add_buddy(ic, MASTODON_OAUTH_HANDLE, NULL); char *url = oauth2_url(md->oauth2_service); char *msg = g_strdup_printf("Open this URL in your browser to authenticate: %s", url); imcb_buddy_msg(ic, MASTODON_OAUTH_HANDLE, msg, 0, 0); g_free(msg); g_free(url); imcb_buddy_msg(ic, MASTODON_OAUTH_HANDLE, "Respond to this message with the returned " "authorization token.", 0, 0); ic->flags |= OPT_SLOW_LOGIN; } int oauth2_refresh(struct im_connection *ic, const char *refresh_token); static void mastodon_login(account_t * acc) { struct im_connection *ic = imcb_new(acc); struct mastodon_data *md = g_new0(struct mastodon_data, 1); url_t url; imcb_log(ic, "Login"); mastodon_connections = g_slist_append(mastodon_connections, ic); ic->proto_data = md; md->user = g_strdup(acc->user); if (!url_set(&url, set_getstr(&ic->acc->set, "base_url"))) { imcb_error(ic, "Cannot parse API base URL: %s", set_getstr(&ic->acc->set, "base_url")); imc_logout(ic, FALSE); return; } if (url.proto != PROTO_HTTPS) { imcb_error(ic, "API base URL must use HTTPS: %s", set_getstr(&ic->acc->set, "base_url")); imc_logout(ic, FALSE); return; } md->url_ssl = 1; md->url_port = url.port; md->url_host = g_strdup(url.host); mastodon_set_name(ic); GSList *p_in = NULL; const char *tok; md->oauth2_service = get_oauth2_service(ic); oauth_params_parse(&p_in, ic->acc->pass); /* If we did not have these stored, register the app and try * again. We'll call oauth2_init from the callback in order to * connect, eventually. */ if (!md->oauth2_service->consumer_key || !md->oauth2_service->consumer_secret || strlen(md->oauth2_service->consumer_key) == 0 || strlen(md->oauth2_service->consumer_secret) == 0) { mastodon_register_app(ic); } /* If we have a refresh token, in which case any access token we *might* have has probably expired already anyway. Refresh and connect. */ else if ((tok = oauth_params_get(&p_in, "refresh_token"))) { oauth2_refresh(ic, tok); } /* If we don't have a refresh token, let's hope the access token is still usable. */ else if ((tok = oauth_params_get(&p_in, "access_token"))) { md->oauth2_access_token = g_strdup(tok); mastodon_connect(ic); } /* If we don't have any, start the OAuth process now. */ else { oauth2_init(ic); } /* All of the above will end up calling mastodon_connect(). */ oauth_params_free(&p_in); } /** * Logout method. Just free the mastodon_data. */ static void mastodon_logout(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; // Set the status to logged out. ic->flags &= ~OPT_LOGGED_IN; if (md) { if (md->timeline_gc) { imcb_chat_free(md->timeline_gc); } GSList *l; for (l = md->streams; l; l = l->next) { struct http_request *req = l->data; http_close(req); } g_slist_free(md->streams); md->streams = NULL; if (md->log) { /* When mastodon_connect hasn't been called, yet, such as when imc_logout is being called from * mastodon_login, the log hasn not yet been initialised. */ int i; for (i = 0; i < MASTODON_LOG_LENGTH; i++) { g_slist_free_full(md->log[i].mentions, g_free); md->log[i].mentions = NULL; g_free(md->log[i].spoiler_text); } g_free(md->log); md->log = NULL; } mastodon_filters_destroy(md); g_slist_free_full(md->mentions, g_free); md->mentions = NULL; g_free(md->last_spoiler_text); md->last_spoiler_text = NULL; g_free(md->spoiler_text); md->spoiler_text = NULL; os_free(md->oauth2_service); md->oauth2_service = NULL; g_free(md->user); md->user = NULL; g_free(md->name); md->name = NULL; g_free(md->next_url); md->next_url = NULL; g_free(md->url_host); md->url_host = NULL; g_free(md); ic->proto_data = NULL; } mastodon_connections = g_slist_remove(mastodon_connections, ic); } /** * When the user replies to the MASTODON_OAUTH_HANDLE with a refresh token we request the access token and this is where * we get it. Save both in our settings and proceed to mastodon_connect. */ void oauth2_got_token(gpointer data, const char *access_token, const char *refresh_token, const char *error) { struct im_connection *ic = data; struct mastodon_data *md; GSList *auth = NULL; if (g_slist_find(mastodon_connections, ic) == NULL) { return; } md = ic->proto_data; if (access_token == NULL) { imcb_error(ic, "OAuth failure (%s)", error); imc_logout(ic, TRUE); return; } oauth_params_parse(&auth, ic->acc->pass); if (refresh_token) { oauth_params_set(&auth, "refresh_token", refresh_token); } if (access_token) { oauth_params_set(&auth, "access_token", access_token); } g_free(ic->acc->pass); ic->acc->pass = oauth_params_string(auth); oauth_params_free(&auth); g_free(md->oauth2_access_token); md->oauth2_access_token = g_strdup(access_token); mastodon_connect(ic); } static gboolean oauth2_remove_contact(gpointer data, gint fd, b_input_condition cond) { struct im_connection *ic = data; if (g_slist_find(mastodon_connections, ic)) { imcb_remove_buddy(ic, MASTODON_OAUTH_HANDLE, NULL); } return FALSE; } /** * Get the refresh token from the user via a reply to MASTODON_OAUTH_HANDLE in mastodon_buddy_msg. * Then get the access token Using the refresh token. The access token is then handled by oauth2_got_token. */ int oauth2_get_refresh_token(struct im_connection *ic, const char *msg) { struct mastodon_data *md = ic->proto_data; char *code; int ret; imcb_log(ic, "Requesting OAuth access token"); /* Don't do it here because the caller may get confused if the contact we're currently sending a message to is deleted. */ b_timeout_add(1, oauth2_remove_contact, ic); code = g_strdup(msg); g_strstrip(code); ret = oauth2_access_token(md->oauth2_service, OAUTH2_AUTH_CODE, code, oauth2_got_token, ic); g_free(code); return ret; } int oauth2_refresh(struct im_connection *ic, const char *refresh_token) { struct mastodon_data *md = ic->proto_data; return oauth2_access_token(md->oauth2_service, OAUTH2_AUTH_REFRESH, refresh_token, oauth2_got_token, ic); } /** * Post a message. Make sure we get all the meta data for the status right. */ static void mastodon_post_message(struct im_connection *ic, char *message, guint64 in_reply_to, char *who, mastodon_message_t type, GSList *mentions, mastodon_visibility_t visibility, char *spoiler_text) { struct mastodon_data *md = ic->proto_data; char *text = NULL; GString *m = NULL; int wlen; char *s; switch (type) { case MASTODON_DIRECT: visibility = MV_DIRECT; // fall through case MASTODON_REPLY: /* Mentioning OP and other mentions is the traditional thing to do. Note that who can be NULL if we're redoing a command like "redo 1234567 foo" where we didn't get any user info from the status id. */ if (!who) break; if (g_ascii_strcasecmp(who, md->user) == 0) { /* if replying to ourselves, we still want to mention others, if any */ m = mastodon_account_join(mentions, NULL); } else { /* if replying to others, mention them, too */ m = mastodon_account_join(mentions, who); } if (m) { text = g_strdup_printf("%s %s", m->str, message); g_string_free(m, TRUE); } /* Note that visibility and spoiler_text have already been set, no need to do anything else. */ break; case MASTODON_NEW_MESSAGE: visibility = md->visibility; /* Note that at the end we will use the default visibility if this is NULL. */ break; case MASTODON_MAYBE_REPLY: { g_assert(visibility == MV_UNKNOWN); wlen = strlen(who); // length of the first word // If the message starts with "nick:" or "nick," if (who && wlen && strncmp(who, message, wlen) == 0 && (s = message + wlen - 1) && (*s == ':' || *s == ',')) { // Trim punctuation from who. who[wlen - 1] = '\0'; // Determine what we are replying to. bee_user_t *bu; if ((bu = bee_user_by_handle(ic->bee, ic, who))) { struct mastodon_user_data *mud = bu->data; if (time(NULL) < mud->last_time + set_getint(&ic->acc->set, "auto_reply_timeout")) { // this is a reply in_reply_to = mud->last_id; // We're always replying to at least one person. bu->handle is fully qualified unlike who m = mastodon_account_join(mud->mentions, bu->handle); visibility = mud->visibility; spoiler_text = mud->spoiler_text; } else { // this is a new message but we still need to prefix the @ and use bu->handle instead of who m = g_string_new("@"); g_string_append(m, bu->handle); } // use +wlen+1 to remove "nick: " (note the space) from message text = g_strdup_printf("%s %s", m->str, message + wlen + 1); g_string_free(m, TRUE); } else if (g_ascii_strcasecmp(who, md->user) == 0) { /* Compare case-insensitively because this is user input. */ /* Same as a above but replying to myself and therefore using mastodon_data (md). We don't set this data to NULL because we might want to send multiple replies to ourselves. We want this to work on a slow instance, so the user can send multiple replies without having to wait for replies to come back and set these values again via mastodon_http_callback. */ in_reply_to = md->last_id; visibility = md->last_visibility; spoiler_text = g_strdup(md->last_spoiler_text); if (md->mentions) { m = mastodon_account_join(md->mentions, NULL); mastodon_log(ic, "Mentions %s", m->str); text = g_strdup_printf("%s %s", m->str, message + wlen + 1); g_string_free(m, TRUE); } else { // use +wlen+1 to remove "nick: " (note the space) from message message += wlen + 1; } } } } break; } if (!mastodon_length_check(ic, text ? text : message, md->spoiler_text ? md->spoiler_text : spoiler_text)) { goto finish; } /* If we explicitly set a visibility for the next toot, use that. Otherwise, use the visibility as determined above, * but make sure that a higher default visibility takes precedence: higher means more private. See * mastodon_visibility_t. */ if (md->visibility != MV_UNKNOWN) { visibility = md->visibility; } else { mastodon_visibility_t default_visibility = mastodon_default_visibility(ic); if (default_visibility > visibility) visibility = default_visibility; } /* md->spoiler_text set by the CW command and md->visibility set by the VISIBILITY command take precedence and get * removed after posting. */ mastodon_post_status(ic, text ? text : message, in_reply_to, visibility, md->spoiler_text ? md->spoiler_text : spoiler_text); g_free(md->spoiler_text); md->spoiler_text = NULL; md->visibility = MV_UNKNOWN; finish: g_free(text); g_free(spoiler_text); } static void mastodon_handle_command(struct im_connection *ic, char *message, mastodon_undo_t undo_type); /** * Send a direct message. If this buddy is the magic mastodon oauth handle, then treat the message as the refresh token. * If this buddy is me, then treat the message as a command. Everything else is a message to a buddy in a query. */ static int mastodon_buddy_msg(struct im_connection *ic, char *who, char *message, int away) { struct mastodon_data *md = ic->proto_data; /* OAuth message to "mastodon_oauth" */ if (g_ascii_strcasecmp(who, MASTODON_OAUTH_HANDLE) == 0 && !(md->flags & OPT_LOGGED_IN)) { if (oauth2_get_refresh_token(ic, message)) { return 1; } else { imcb_error(ic, "OAuth failure"); imc_logout(ic, TRUE); return 0; } } if (g_ascii_strcasecmp(who, md->name) == 0) { /* Message to ourselves */ mastodon_handle_command(ic, message, MASTODON_NEW); } else { /* Determine who and to what post id we are replying to */ guint64 in_reply_to = 0; bee_user_t *bu; if ((bu = bee_user_by_handle(ic->bee, ic, who))) { struct mastodon_user_data *mud = bu->data; if (time(NULL) < mud->last_direct_time + set_getint(&ic->acc->set, "auto_reply_timeout")) { /* this is a reply */ in_reply_to = mud->last_direct_id; } } mastodon_post_message(ic, message, in_reply_to, who, MASTODON_REPLY, NULL, MV_DIRECT, NULL); } return 0; } static void mastodon_user(struct im_connection *ic, char *who); static void mastodon_get_info(struct im_connection *ic, char *who) { struct mastodon_data *md = ic->proto_data; struct irc_channel *ch = md->timeline_gc->ui_data; imcb_log(ic, "Sending output to %s", ch->name); if (g_ascii_strcasecmp(who, md->name) == 0) { mastodon_instance(ic); } else { mastodon_user(ic, who); } } static void mastodon_chat_msg(struct groupchat *c, char *message, int flags) { if (c && message) { mastodon_handle_command(c->ic, message, MASTODON_NEW); } } /** * Joining a group chat means showing the appropriate timeline and start streaming it. */ static struct groupchat *mastodon_chat_join(struct im_connection *ic, const char *room, const char *nick, const char *password, set_t **sets) { char *topic = g_strdup(room); struct groupchat *c = imcb_chat_new(ic, topic); imcb_chat_topic(c, NULL, topic, 0); imcb_chat_add_buddy(c, ic->acc->user); struct http_request *req = NULL; if (strcmp(topic, "local") == 0) { mastodon_local_timeline(ic); req = mastodon_open_local_stream(ic); } else if (strcmp(topic, "federated") == 0) { mastodon_federated_timeline(ic); req = mastodon_open_federated_stream(ic); } else if (topic[0] == '#') { mastodon_hashtag_timeline(ic, topic + 1); req = mastodon_open_hashtag_stream(ic, topic + 1); } else { /* After the initial login we cannot be sure that an initial list timeline will work because the lists are not loaded, yet. That's why mastodon_following() will end up reloading the lists with the extra parameter which will load these timelines. If we're creating this channel at a later point, however, this should be possible. One way to determine if we're "at a later point" is by looking at MASTODON_HAVE_FRIENDS. It's actually not quite correct: at this point we have the lists but not the list members, but it should be good enough as we're only interested in later chat joining, not auto_join. */ struct mastodon_data *md = ic->proto_data; if (md->flags & MASTODON_HAVE_FRIENDS) { mastodon_unknown_list_timeline(ic, topic); } /* We need to identify the list we're going to stream but we don't get a request on the return from mastodon_open_unknown_list_stream(). Instead, we pass the channel along and when we have the list, the request will be set accordingly. */ mastodon_open_unknown_list_stream(ic, c, topic); } g_free(topic); c->data = req; return c; } /** * If the user leaves the main channel: Fine. Rejoin him/her once new toots come in. But what if the user leaves a * channel that is connected to a stream? In this case we need to find the appropriate stream and close it, too. */ static void mastodon_chat_leave(struct groupchat *c) { GSList *l; struct mastodon_data *md = c->ic->proto_data; if (c == md->timeline_gc) { md->timeline_gc = NULL; } else { struct http_request *stream = c->data; for (l = md->streams; l; l = l->next) { struct http_request *req = l->data; if (stream == req) { md->streams = g_slist_remove(md->streams, req); http_close(req); break; } } } imcb_chat_free(c); } static void mastodon_add_permit(struct im_connection *ic, char *who) { } static void mastodon_rem_permit(struct im_connection *ic, char *who) { } static void mastodon_buddy_data_add(bee_user_t *bu) { bu->data = g_new0(struct mastodon_user_data, 1); } static void mastodon_buddy_data_free(bee_user_t *bu) { struct mastodon_user_data *mud = (struct mastodon_user_data*) bu->data; g_slist_free_full(mud->lists, g_free); mud->lists = NULL; g_slist_free_full(mud->mentions, g_free); mud->mentions = NULL; g_free(mud->spoiler_text); mud->spoiler_text = NULL; g_free(bu->data); } bee_user_t mastodon_log_local_user; /** * Find a user account based on their nick name. */ static bee_user_t *mastodon_user_by_nick(struct im_connection *ic, char *nick) { GSList *l; for (l = ic->bee->users; l; l = l->next) { bee_user_t *bu = l->data; irc_user_t *iu = bu->ui_data; if (g_ascii_strcasecmp(iu->nick, nick) == 0) { /* Compare case-insentively because this is user input. */ return bu; } } return NULL; } /** * Convert the given bitlbee toot ID or bitlbee username into a * mastodon status ID and returns it. If provided with a pointer to a * bee_user_t, fills that as well. Provide NULL if you don't need it. * The same is true for mentions, visibility and spoiler text. * * Returns 0 if the user provides garbage. */ static guint64 mastodon_message_id_from_command_arg(struct im_connection *ic, char *arg, bee_user_t **bu_, GSList **mentions_, mastodon_visibility_t *visibility_, char **spoiler_text_) { struct mastodon_data *md = ic->proto_data; struct mastodon_user_data *mud; bee_user_t *bu = NULL; guint64 id = 0; if (bu_) { *bu_ = NULL; } if (!arg || !arg[0]) { return 0; } if (arg[0] != '#' && (bu = mastodon_user_by_nick(ic, arg))) { if ((mud = bu->data)) { id = mud->last_id; if (mentions_) *mentions_ = mud->mentions; if (visibility_) *visibility_ = mud->visibility; if (spoiler_text_) *spoiler_text_ = mud->spoiler_text; } } else { if (arg[0] == '#') { arg++; } if (parse_int64(arg, 16, &id) && id < MASTODON_LOG_LENGTH) { if (mentions_) *mentions_ = md->log[id].mentions; if (visibility_) *visibility_ = md->log[id].visibility; if (spoiler_text_) *spoiler_text_ = md->log[id].spoiler_text; bu = md->log[id].bu; id = md->log[id].id; } else if (parse_int64(arg, 10, &id)) { /* Allow normal toot IDs as well. Required do undo posts, for example. */ } else { /* Reset id if id was a valid hex number but >= MASTODON_LOG_LENGTH. */ id = 0; } } if (bu_) { if (bu == &mastodon_log_local_user) { /* HACK alert. There's no bee_user object for the local * user so just fake one for the few cmds that need it. */ mastodon_log_local_user.handle = md->user; } else { /* Beware of dangling pointers! */ if (!g_slist_find(ic->bee->users, bu)) { bu = NULL; } } *bu_ = bu; } return id; } static void mastodon_no_id_warning(struct im_connection *ic, char *what) { mastodon_log(ic, "User or status '%s' is unknown.", what); } static void mastodon_unknown_user_warning(struct im_connection *ic, char *who) { mastodon_log(ic, "User '%s' is unknown.", who); } /** * Get the message id given a nick or a status id. If possible, also set a number of other variables by reference. */ static guint64 mastodon_message_id_or_warn_and_more(struct im_connection *ic, char *what, bee_user_t **bu, GSList **mentions, mastodon_visibility_t *visibility, char **spoiler_text) { guint64 id = mastodon_message_id_from_command_arg(ic, what, bu, mentions, visibility, spoiler_text); if (!id) { mastodon_no_id_warning(ic, what); } return id; } /** * Simple interface to mastodon_message_id_or_warn_and_more. Get the message id given a nick or a status id. */ static guint64 mastodon_message_id_or_warn(struct im_connection *ic, char *what) { return mastodon_message_id_or_warn_and_more(ic, what, NULL, NULL, NULL, NULL); } static guint64 mastodon_account_id(bee_user_t *bu) { struct mastodon_user_data *mud; if (bu != NULL && (mud = bu->data)) { return mud->account_id; } return 0; } static guint64 mastodon_user_id_or_warn(struct im_connection *ic, char *who) { bee_user_t *bu; guint64 id; if ((bu = mastodon_user_by_nick(ic, who)) && (id = mastodon_account_id(bu))) { return id; } else if (parse_int64(who, 10, &id)) { return id; } mastodon_unknown_user_warning(ic, who); return 0; } static void mastodon_user(struct im_connection *ic, char *who) { bee_user_t *bu; guint64 id; if ((bu = mastodon_user_by_nick(ic, who)) && (id = mastodon_account_id(bu))) { mastodon_account(ic, id); } else { mastodon_search_account(ic, who); } } static void mastodon_relation_to_user(struct im_connection *ic, char *who) { bee_user_t *bu; guint64 id; if ((bu = mastodon_user_by_nick(ic, who)) && (id = mastodon_account_id(bu))) { mastodon_relationship(ic, id); } else { mastodon_search_relationship(ic, who); } } static void mastodon_add_buddy(struct im_connection *ic, char *who, char *group) { bee_user_t *bu; guint64 id; if ((bu = mastodon_user_by_nick(ic, who)) && (id = mastodon_account_id(bu))) { // If the nick is already in the channel (when we just // unfollowed them, for example), we're taking a // shortcut. No fancy looking at the relationship and // all that. The nick is already here, after all. mastodon_post(ic, MASTODON_ACCOUNT_FOLLOW_URL, MC_FOLLOW, id); } else if (parse_int64(who, 10, &id)) { // If we provided a numerical id, then that will also // work. This is used by redo/undo. mastodon_post(ic, MASTODON_ACCOUNT_FOLLOW_URL, MC_FOLLOW, id); } else { // Alternatively, we're looking for an unknown user. // They must be searched, followed, and added to the // channel. It's going to take more requests. mastodon_follow(ic, who); } } static void mastodon_remove_buddy(struct im_connection *ic, char *who, char *group) { guint64 id; if ((id = mastodon_user_id_or_warn(ic, who))) { mastodon_post(ic, MASTODON_ACCOUNT_UNFOLLOW_URL, MC_UNFOLLOW, id); } } static void mastodon_add_deny(struct im_connection *ic, char *who) { guint64 id; if ((id = mastodon_user_id_or_warn(ic, who))) { mastodon_post(ic, MASTODON_ACCOUNT_BLOCK_URL, MC_BLOCK, id); } } static void mastodon_rem_deny(struct im_connection *ic, char *who) { guint64 id; if ((id = mastodon_user_id_or_warn(ic, who))) { mastodon_post(ic, MASTODON_ACCOUNT_UNBLOCK_URL, MC_UNBLOCK, id); } } /** * Add a command and a way to undo it to the undo stack. Remember that * only the callback knows whether a command succeeded or not, and * what the id of a newly posted status is, and all that. Thus, * there's a delay that we need to take into account. * * The stack is organized as follows if we just did D: * 0 1 2 3 4 5 6 7 8 9 * undo = [a b c d e f g h i j] * redo = [A B C D E F G H I J] * first_undo = 3 * current_undo = 3 * If we do X: * undo = [a b c d x f g h i j] * redo = [A B C D X F G H I J] * first_undo = 4 * current_undo = 4 * If we undo it, send x and: * undo = [a b c d x f g h i j] * redo = [A B C D X F G H I J] * first_undo = 4 * current_undo = 3 * If we redo, send X and increase current_undo. * If we undo instead, send d and decrease current_undo again: * undo = [a b c d x f g h i j] * redo = [A B C D X F G H I J] * first_undo = 4 * current_undo = 2 * If we do Y with current_undo different from first_undo, null the tail: * undo = [a b c y 0 f g h i j] * redo = [A B C Y 0 F G H I J] * first_undo = 3 * current_undo = 3 */ void mastodon_do(struct im_connection *ic, char *redo, char *undo) { struct mastodon_data *md = ic->proto_data; int i = (md->current_undo + 1) % MASTODON_MAX_UNDO; g_free(md->redo[i]); g_free(md->undo[i]); md->redo[i] = redo; md->undo[i] = undo; if (md->current_undo == md->first_undo) { md->current_undo = md->first_undo = i; } else { md->current_undo = i; int end = (md->first_undo + 1) % MASTODON_MAX_UNDO; for (i = (md->current_undo + 1) % MASTODON_MAX_UNDO; i != end; i = (i + 1) % MASTODON_MAX_UNDO) { g_free(md->redo[i]); g_free(md->undo[i]); md->redo[i] = NULL; md->undo[i] = NULL; } md->first_undo = md->current_undo; } } /** * Undo the last command. */ void mastodon_undo(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; char *cmd = md->undo[md->current_undo]; if (!cmd) { mastodon_log(ic, "There is nothing to undo."); return; } gchar **cmds = g_strsplit (cmd, FS, -1); int i; for (i = 0; cmds[i]; i++) { mastodon_handle_command(ic, cmds[i], MASTODON_UNDO); } g_strfreev(cmds); // beware of negatives and modulo md->current_undo = (md->current_undo + MASTODON_MAX_UNDO - 1) % MASTODON_MAX_UNDO; } /** * Redo the last command. Multiple commands can be executed as one using the ASCII Field Separator (FS). */ void mastodon_redo(struct im_connection *ic) { struct mastodon_data *md = ic->proto_data; if (md->current_undo == md->first_undo) { mastodon_log(ic, "There is nothing to redo."); return; } md->current_undo = (md->current_undo + 1) % MASTODON_MAX_UNDO; char *cmd = md->redo[md->current_undo]; gchar **cmds = g_strsplit (cmd, FS, -1); int i; for (i = 0; cmds[i]; i++) { mastodon_handle_command(ic, cmds[i], MASTODON_REDO); } g_strfreev(cmds); } /** * Update the current command in the stack. This is necessary when * executing commands which change references that we saved. For * example: every delete statement refers to an id. Whenever a post * happens because of redo, the delete command in the undo stack has * to be replaced. Whenever a post happens because of undo, the delete * command in the redo stack has to be replaced. * * We make our own copies of 'to'. */ void mastodon_do_update(struct im_connection *ic, char *to) { struct mastodon_data *md = ic->proto_data; char *from = NULL; int i; switch (md->undo_type) { case MASTODON_NEW: // should not happen return; case MASTODON_UNDO: // after post due to undo of a delete statement, the // old delete statement is in the next redo element i = (md->current_undo + 1) % MASTODON_MAX_UNDO; from = g_strdup(md->redo[i]); break; case MASTODON_REDO: // after post due to redo of a post statement, the // old delete statement is in the undo element i = md->current_undo; from = g_strdup(md->undo[i]); break; } /* After a post and a delete of that post, there are at least * two cells where the old reference can be hiding (undo of * the post and redo of the delete). Brute force! */ for (i = 0; i < MASTODON_MAX_UNDO; i++) { if (md->undo[i] && strcmp(from, md->undo[i]) == 0) { g_free(md->undo[i]); md->undo[i] = g_strdup(to); break; } } for (i = 0; i < MASTODON_MAX_UNDO; i++) { if (md->redo[i] && strcmp(from, md->redo[i]) == 0) { g_free(md->redo[i]); md->redo[i] = g_strdup(to); break; } } g_free(from); } /** * Show the current history. The history shows the redo * commands. */ void mastodon_history(struct im_connection *ic, gboolean undo_history) { struct mastodon_data *md = ic->proto_data; int i; for (i = 0; i < MASTODON_MAX_UNDO; i++) { // start with the last int n = (md->first_undo + i + 1) % MASTODON_MAX_UNDO; char *cmd = undo_history ? md->undo[n] : md->redo[n]; if (cmd) { gchar **cmds = g_strsplit (cmd, FS, -1); int j; for (j = 0; cmds[j]; j++) { if (n == md->current_undo) { mastodon_log(ic, "%02d > %s", MASTODON_MAX_UNDO - i, cmds[j]); } else { mastodon_log(ic, "%02d %s", MASTODON_MAX_UNDO - i, cmds[j]); } } g_strfreev(cmds); } } } /** * Commands we understand. Changes should be documented in * doc/mastodon-help.txt and on https://wiki.bitlbee.org/HowtoMastodon */ static void mastodon_handle_command(struct im_connection *ic, char *message, mastodon_undo_t undo_type) { struct mastodon_data *md = ic->proto_data; gboolean allow_post = g_ascii_strcasecmp(set_getstr(&ic->acc->set, "commands"), "strict") != 0; bee_user_t *bu = NULL; guint64 id; md->undo_type = undo_type; char *cmds = g_strdup(message); char **cmd = split_command_parts(cmds, 2); if (cmd[0] == NULL) { /* Nothing to do */ } else if (!set_getbool(&ic->acc->set, "commands") && allow_post) { /* Not supporting commands if "commands" is set to true/strict. */ } else if (g_ascii_strcasecmp(cmd[0], "help") == 0) { /* For unsupported undo and redo commands. */ mastodon_log(ic, "Please use help mastodon in the control channel, &bitlbee."); } else if (g_ascii_strcasecmp(cmd[0], "info") == 0) { if (!cmd[1]) { mastodon_log(ic, "Usage:\n" "- info instance\n" "- info [id|screenname]\n" "- info user [nick|account]\n" "- info relation [nick|account]\n" "- info [get|put|post|delete] url [args]"); } else if (g_ascii_strcasecmp(cmd[1], "instance") == 0) { mastodon_instance(ic); } else if (g_ascii_strcasecmp(cmd[1], "user") == 0) { if (cmd[2]) { mastodon_user(ic, cmd[2]); } else { mastodon_log(ic, "User info about whom?"); } } else if (g_ascii_strcasecmp(cmd[1], "relation") == 0) { if (cmd[2]) { mastodon_relation_to_user(ic, cmd[2]); } else { mastodon_log(ic, "Relation with whom?"); } } else if ((id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_status(ic, id); } } else if (g_ascii_strcasecmp(cmd[0], "api") == 0) { if (!cmd[1] || !cmd[2]) { mastodon_log(ic, "Usage: api [get|put|post|delete] endpoint params...\n" "Example: api post /lists/12/accounts account_ids[] 321"); } else if ((g_ascii_strcasecmp(cmd[1], "get") == 0 || g_ascii_strcasecmp(cmd[1], "put") == 0 || g_ascii_strcasecmp(cmd[1], "post") == 0 || g_ascii_strcasecmp(cmd[1], "delete") == 0) && cmd[2]) { char *s = strstr(cmd[2], " "); if (s) { *s = '\0'; char **args = g_strsplit(s+1, " ", 0); /* find length of null-terminated vector */ int i = 0; for (; args[i]; i++); if (i % 2) { mastodon_log(ic, "Wrong number of arguments. Did you forget the URL?"); } else { mastodon_raw(ic, cmd[1], cmd[2], args, i); } g_strfreev(args); } else { mastodon_raw(ic, cmd[1], cmd[2], NULL, 0); } } else { mastodon_log(ic, "Usage: 'api [get|put|post|delete] url [name value]*"); } } else if (g_ascii_strcasecmp(cmd[0], "undo") == 0) { if (cmd[1] == NULL) { mastodon_undo(ic); } else { // because it used to take an argument mastodon_log(ic, "Undo takes no arguments."); } } else if (g_ascii_strcasecmp(cmd[0], "redo") == 0) { if (cmd[1] == NULL) { mastodon_redo(ic); } else { mastodon_log(ic, "Redo takes no arguments."); } } else if (g_ascii_strcasecmp(cmd[0], "his") == 0 || g_ascii_strcasecmp(cmd[0], "history") == 0) { if (cmd[1] && g_ascii_strcasecmp(cmd[1], "undo") == 0) { mastodon_history(ic, TRUE); } else if (cmd[1] == NULL) { mastodon_history(ic, FALSE); } else { mastodon_log(ic, "History only takes the optional undo argument."); } } else if (g_ascii_strcasecmp(cmd[0], "del") == 0 || g_ascii_strcasecmp(cmd[0], "delete") == 0) { if (cmd[1] == NULL && md->last_id) { mastodon_status_delete(ic, md->last_id); } else if (cmd[1] && (id = mastodon_message_id_from_command_arg(ic, cmd[1], NULL, NULL, NULL, NULL))) { mastodon_status_delete(ic, id); } else { mastodon_log(ic, "Could not delete the last post."); } } else if ((g_ascii_strcasecmp(cmd[0], "favourite") == 0 || g_ascii_strcasecmp(cmd[0], "favorite") == 0 || g_ascii_strcasecmp(cmd[0], "fav") == 0 || g_ascii_strcasecmp(cmd[0], "like") == 0)) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_post(ic, MASTODON_STATUS_FAVOURITE_URL, MC_FAVOURITE, id); } else { mastodon_log(ic, "Huh? Please provide a log number or nick."); } } else if ((g_ascii_strcasecmp(cmd[0], "unfavourite") == 0 || g_ascii_strcasecmp(cmd[0], "unfavorite") == 0 || g_ascii_strcasecmp(cmd[0], "unfav") == 0 || g_ascii_strcasecmp(cmd[0], "unlike") == 0 || g_ascii_strcasecmp(cmd[0], "dislike") == 0)) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_post(ic, MASTODON_STATUS_UNFAVOURITE_URL, MC_UNFAVOURITE, id); } else { mastodon_log(ic, "What? Please provide a log number or nick."); } } else if (g_ascii_strcasecmp(cmd[0], "pin") == 0) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_post(ic, MASTODON_STATUS_PIN_URL, MC_PIN, id); } else { mastodon_log(ic, "Sorry, what? Please provide a log number or nick."); } } else if (g_ascii_strcasecmp(cmd[0], "unpin") == 0) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_post(ic, MASTODON_STATUS_UNPIN_URL, MC_UNPIN, id); } else { mastodon_log(ic, "No can do! I need a a log number or nick."); } } else if (g_ascii_strcasecmp(cmd[0], "follow") == 0) { if (cmd[1]) { mastodon_add_buddy(ic, cmd[1], NULL); } else { mastodon_log(ic, "I'm confused! Follow whom?"); } } else if (g_ascii_strcasecmp(cmd[0], "unfollow") == 0) { if (cmd[1]) { mastodon_remove_buddy(ic, cmd[1], NULL); } else { mastodon_log(ic, "Unfollow whom?"); } } else if (g_ascii_strcasecmp(cmd[0], "block") == 0) { if (cmd[1]) { mastodon_add_deny(ic, cmd[1]); } else { mastodon_log(ic, "Whom should I block?"); } } else if (g_ascii_strcasecmp(cmd[0], "unblock") == 0 || g_ascii_strcasecmp(cmd[0], "allow") == 0) { if (cmd[1]) { mastodon_rem_deny(ic, cmd[1]); } else { mastodon_log(ic, "Unblock who?"); } } else if (g_ascii_strcasecmp(cmd[0], "mute") == 0 && g_ascii_strcasecmp(cmd[1], "user") == 0) { if (cmd[2] && (id = mastodon_user_id_or_warn(ic, cmd[2]))) { mastodon_post(ic, MASTODON_ACCOUNT_MUTE_URL, MC_ACCOUNT_MUTE, id); } else { mastodon_log(ic, "Mute user? I also need a nick!"); } } else if (g_ascii_strcasecmp(cmd[0], "unmute") == 0 && g_ascii_strcasecmp(cmd[1], "user") == 0) { if (cmd[2] && (id = mastodon_user_id_or_warn(ic, cmd[2]))) { mastodon_post(ic, MASTODON_ACCOUNT_UNMUTE_URL, MC_ACCOUNT_UNMUTE, id); } else { mastodon_log(ic, "Sure, unmute a user. But who is it? Give me a nick!"); } } else if (g_ascii_strcasecmp(cmd[0], "mute") == 0) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_post(ic, MASTODON_STATUS_MUTE_URL, MC_STATUS_MUTE, id); } else { mastodon_log(ic, "Muting? Please provide a log number or nick!"); } } else if (g_ascii_strcasecmp(cmd[0], "unmute") == 0) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_post(ic, MASTODON_STATUS_UNMUTE_URL, MC_STATUS_UNMUTE, id); } else { mastodon_log(ic, "OK, I'll unmute something. But what? I need a log number or nick."); } } else if (g_ascii_strcasecmp(cmd[0], "boost") == 0) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_post(ic, MASTODON_STATUS_BOOST_URL, MC_BOOST, id); } else { mastodon_log(ic, "Failed to boost! Please provide a log number or nick."); } } else if (g_ascii_strcasecmp(cmd[0], "unboost") == 0) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_post(ic, MASTODON_STATUS_UNBOOST_URL, MC_UNBOOST, id); } else { mastodon_log(ic, "Argh, #fail! Please provide a log number or nick."); } } else if (g_ascii_strcasecmp(cmd[0], "url") == 0) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_status_show_url(ic, id); } else { mastodon_log(ic, "This is confusing. Do you have a log number or nick?"); } } else if ((g_ascii_strcasecmp(cmd[0], "whois") == 0 || g_ascii_strcasecmp(cmd[0], "who") == 0)) { if (!cmd[1]) { mastodon_log(ic, "The IRC command /names should give you a list."); } else if ((bu = mastodon_user_by_nick(ic, cmd[1]))) { mastodon_log(ic, "%s [%s]", bu->handle, bu->fullname); } else if ((parse_int64(cmd[1], 16, &id) && id < MASTODON_LOG_LENGTH)) { mastodon_show_mentions(ic, md->log[id].mentions); } else if ((parse_int64(cmd[1], 10, &id))) { mastodon_status_show_mentions(ic, id); } else if (g_ascii_strcasecmp(cmd[1], md->user) == 0) { mastodon_log(ic, "This is you!"); } else { mastodon_unknown_user_warning(ic, cmd[1]); } } else if (g_ascii_strcasecmp(cmd[0], "report") == 0 || g_ascii_strcasecmp(cmd[0], "spam") == 0) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { if (!cmd[2] || strlen(cmd[2]) == 0) { mastodon_log(ic, "You must provide a comment with your report."); } else { mastodon_report(ic, id, cmd[2]); } } else { mastodon_log(ic, "I need a log number or nick, and a comment!"); } } else if (g_ascii_strcasecmp(cmd[0], "search") == 0) { if (cmd[1]) { mastodon_search(ic, cmd[1]); } else { mastodon_log(ic, "Sure, but what?"); } } else if (g_ascii_strcasecmp(cmd[0], "context") == 0) { if (cmd[1] && (id = mastodon_message_id_or_warn(ic, cmd[1]))) { mastodon_context(ic, id); } else { mastodon_log(ic, "Context of what, though? Please provide a log number or nick."); } } else if (g_ascii_strcasecmp(cmd[0], "timeline") == 0) { if (!cmd[1] || strcmp(cmd[1], "home") == 0) { mastodon_home_timeline(ic); } else if ((bu = mastodon_user_by_nick(ic, cmd[1])) && (id = mastodon_account_id(bu))) { mastodon_account_statuses(ic, id); } else if (*cmd[1] == '#') { mastodon_hashtag_timeline(ic, cmd[1] + 1); } else if (*cmd[1] == '@') { mastodon_unknown_account_statuses(ic, cmd[1] + 1); } else if (strcmp(cmd[1], "local") == 0) { mastodon_local_timeline(ic); } else if (strcmp(cmd[1], "federated") == 0) { mastodon_federated_timeline(ic); } else { mastodon_unknown_list_timeline(ic, message + 9); // "timeline %s" } } else if (g_ascii_strcasecmp(cmd[0], "notifications") == 0) { if (cmd[1] == NULL) { mastodon_notifications(ic); } else { mastodon_log(ic, "Notifications takes no arguments."); } } else if (g_ascii_strcasecmp(cmd[0], "pinned") == 0) { if (!cmd[1]) { mastodon_log(ic, "Pin the void? I need a nick or an account."); } else if ((bu = mastodon_user_by_nick(ic, cmd[1])) && (id = mastodon_account_id(bu))) { mastodon_account_pinned_statuses(ic, id); } else { mastodon_unknown_account_pinned_statuses(ic, cmd[1]); } } else if (g_ascii_strcasecmp(cmd[0], "bio") == 0) { if (!cmd[1]) { mastodon_log(ic, "Bio what? Please provide a nick or an account."); } else if ((bu = mastodon_user_by_nick(ic, cmd[1])) && (id = mastodon_account_id(bu))) { mastodon_account_bio(ic, id); } else { mastodon_unknown_account_bio(ic, cmd[1]); } } else if (g_ascii_strcasecmp(cmd[0], "more") == 0) { if (cmd[1]) { mastodon_log(ic, "More takes no arguments."); } else if (md->next_url) { mastodon_more(ic); } else { mastodon_log(ic, "More of what? Use the timeline command, first."); } } else if (g_ascii_strcasecmp(cmd[0], "list") == 0) { if (!cmd[1]) { mastodon_lists(ic); } else if (g_ascii_strcasecmp(cmd[1], "create") == 0) { if (!cmd[2]) { mastodon_log(ic, "You forgot the title of the new list!"); } else { mastodon_list_create(ic, message + 12); // "list create %s" } } else if (g_ascii_strcasecmp(cmd[1], "reload") == 0) { if (cmd[2]) { mastodon_log(ic, "List reloading takes no argument"); } else { mastodon_list_reload(ic, FALSE); } } else if (g_ascii_strcasecmp(cmd[1], "delete") == 0) { if (!cmd[2]) { mastodon_log(ic, "Which list should be deleted? Use list to find out."); } else { mastodon_unknown_list_delete(ic, message + 12); // "list delete %s" } } else if (g_ascii_strcasecmp(cmd[1], "add") == 0) { char **args = g_strsplit(cmd[2], " to ", 2); if (args[0] && args[1] && (id = mastodon_user_id_or_warn(ic, args[0]))) { mastodon_unknown_list_add_account(ic, id, args[1]); } else { mastodon_log(ic, "I am confused. Please use list add <nick> to <list>."); } g_strfreev(args); } else if (g_ascii_strcasecmp(cmd[1], "remove") == 0) { char **args = g_strsplit(cmd[2], " from ", 2); if (args[0] && args[1] && (id = mastodon_user_id_or_warn(ic, args[0]))) { mastodon_unknown_list_remove_account(ic, id, args[1]); } else { mastodon_log(ic, "I need to what to do! Use list remove <nick> from <list>."); } g_strfreev(args); } else { mastodon_unknown_list_accounts(ic, message + 5); // "list %s" } } else if (g_ascii_strcasecmp(cmd[0], "filter") == 0) { if (!cmd[1]) { mastodon_filters(ic); } else if (g_ascii_strcasecmp(cmd[1], "create") == 0) { if (!cmd[2]) { mastodon_log(ic, "What do you want to filter?"); } else { mastodon_filter_create(ic, message + 14); // "filter create %s" } } else if (g_ascii_strcasecmp(cmd[1], "delete") == 0) { if (!cmd[2]) { mastodon_log(ic, "Which filter should be deleted? Use filter to find out."); } else { mastodon_filter_delete(ic, cmd[2]); } } else { mastodon_log(ic, "I only understand the filter subcommands create and delete."); } } else if (g_ascii_strcasecmp(cmd[0], "reply") == 0) { if (!cmd[1] || !cmd[2]) { mastodon_log(ic, "Sorry, what? Please provide a log number or nick, and your reply."); } else { /* These three variables will be set, if we find the toot we are replying to in our log or in the * mastodon_user_data (mud). If we are replying to a fixed id, then we'll get an id and the three variables * remain untouched, so handle them with care. */ GSList *mentions = NULL; char *spoiler_text = NULL; mastodon_visibility_t visibility = MV_UNKNOWN; if ((id = mastodon_message_id_or_warn_and_more(ic, cmd[1], &bu, &mentions, &visibility, &spoiler_text))) { mastodon_visibility_t default_visibility = mastodon_default_visibility(ic); if (default_visibility > visibility) visibility = default_visibility; char *who = bu ? bu->handle : NULL; mastodon_post_message(ic, cmd[2], id, who, MASTODON_REPLY, mentions, visibility, spoiler_text); } else { mastodon_log(ic, "Sorry, I can't figure out what you're reply to!"); } } } else if (g_ascii_strcasecmp(cmd[0], "cw") == 0) { g_free(md->spoiler_text); if (cmd[1] == NULL) { md->spoiler_text = NULL; mastodon_log(ic, "Next post will get no content warning"); } else { md->spoiler_text = g_strdup(message + 3); mastodon_log(ic, "Next post will get content warning '%s'", md->spoiler_text); } } else if ((g_ascii_strcasecmp(cmd[0], "visibility") == 0 || g_ascii_strcasecmp(cmd[0], "vis") == 0)) { if (cmd[1] == NULL) { md->visibility = mastodon_default_visibility(ic); } else { md->visibility = mastodon_parse_visibility(cmd[1]); } mastodon_log(ic, "Next post is %s", mastodon_visibility(md->visibility)); } else if (g_ascii_strcasecmp(cmd[0], "post") == 0) { if (cmd[1] == NULL) { mastodon_log(ic, "What should we post?"); } else { mastodon_post_message(ic, message + 5, 0, cmd[1], MASTODON_NEW_MESSAGE, NULL, MV_UNKNOWN, NULL); } } else if (g_ascii_strcasecmp(cmd[0], "public") == 0 || g_ascii_strcasecmp(cmd[0], "unlisted") == 0 || g_ascii_strcasecmp(cmd[0], "private") == 0 || g_ascii_strcasecmp(cmd[0], "direct") == 0) { mastodon_log(ic, "Please use the visibility command instead"); } else if (allow_post) { mastodon_post_message(ic, message, 0, cmd[0], MASTODON_MAYBE_REPLY, NULL, MV_UNKNOWN, NULL); } else { mastodon_log(ic, "Unknown command: %s", cmd[0]); } g_free(cmds); } void mastodon_log(struct im_connection *ic, char *format, ...) { struct mastodon_data *md = ic->proto_data; va_list params; char *text; va_start(params, format); text = g_strdup_vprintf(format, params); va_end(params); if (md->timeline_gc) { imcb_chat_log(md->timeline_gc, "%s", text); } else { imcb_log(ic, "%s", text); } g_free(text); } G_MODULE_EXPORT void init_plugin(void) { struct prpl *ret = g_new0(struct prpl, 1); ret->options = PRPL_OPT_NOOTR | PRPL_OPT_NO_PASSWORD; ret->name = "mastodon"; ret->login = mastodon_login; ret->init = mastodon_init; ret->logout = mastodon_logout; ret->buddy_msg = mastodon_buddy_msg; ret->get_info = mastodon_get_info; ret->add_buddy = mastodon_add_buddy; ret->remove_buddy = mastodon_remove_buddy; ret->chat_msg = mastodon_chat_msg; ret->chat_join = mastodon_chat_join; ret->chat_leave = mastodon_chat_leave; ret->add_permit = mastodon_add_permit; ret->rem_permit = mastodon_rem_permit; ret->add_deny = mastodon_add_deny; ret->rem_deny = mastodon_rem_deny; ret->buddy_data_add = mastodon_buddy_data_add; ret->buddy_data_free = mastodon_buddy_data_free; ret->handle_cmp = g_ascii_strcasecmp; register_protocol(ret); } �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/src/mastodon.h���������������������������������������������������������������0000664�0000000�0000000�00000016070�13634420343�0017542�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������/***************************************************************************\ * * * BitlBee - An IRC to IM gateway * * Simple module to facilitate Mastodon functionality. * * * * Copyright 2009-2010 Geert Mulders <g.c.w.m.mulders@gmail.com> * * Copyright 2010-2012 Wilmer van der Gaast <wilmer@gaast.net> * * Copyright 2017-2019 Alex Schroeder <alex@gnu.org> * * * * This library is free software; you can redistribute it and/or * * modify it under the terms of the GNU Lesser General Public * * License as published by the Free Software Foundation, version * * 2.1. * * * * This library is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * * Lesser General Public License for more details. * * * * You should have received a copy of the GNU Lesser General Public License * * along with this library; if not, write to the Free Software Foundation, * * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA * * * ****************************************************************************/ #include "nogaim.h" #pragma once #ifdef DEBUG_MASTODON #define debug(...) imcb_log(ic, __VA_ARGS__); #else #define debug(...) #endif #define MASTODON_OAUTH_HANDLE "mastodon_oauth" #define MASTODON_SCOPE "read+write+follow" // URL escaped #define MASTODON_URL_REGEX "https?://\\S+" #define MASTODON_MENTION_REGEX "@(([a-zA-Z0-9_]+)@[a-zA-Z0-9.-]+[a-zA-Z0-9])" typedef enum { MASTODON_HAVE_FRIENDS = 0x00001, MASTODON_MODE_ONE = 0x00002, MASTODON_MODE_MANY = 0x00004, MASTODON_MODE_CHAT = 0x00008, MASTODON_GOT_TIMELINE = 0x00010, MASTODON_GOT_NOTIFICATIONS = 0x00020, MASTODON_GOT_FILTERS = 0x00040, MASTODON_GOT_STATUS = 0x00100, MASTODON_GOT_CONTEXT = 0x00200, } mastodon_flags_t; typedef enum { MASTODON_DIRECT, MASTODON_REPLY, MASTODON_MAYBE_REPLY, MASTODON_NEW_MESSAGE, } mastodon_message_t; /** * Visibility of a status. MV_UNKNOWN mean that we will use the default visibility when posting. * Higher has precedence! */ typedef enum { MV_UNKNOWN, MV_PUBLIC, MV_UNLISTED, MV_PRIVATE, MV_DIRECT, } mastodon_visibility_t; /** * Various things that come in pages such that the "more" command needs to know about it. */ typedef enum { MASTODON_MORE_STATUSES, MASTODON_MORE_NOTIFICATIONS, } mastodon_more_t; /** * These are the various ways a command can influence the undo/redo * queue. */ typedef enum { MASTODON_NEW, MASTODON_UNDO, MASTODON_REDO, } mastodon_undo_t; #define FS "\x1e" /** * These are the commands that can be undone and redone. */ typedef enum { MC_UNKNOWN, MC_POST, MC_DELETE, MC_FOLLOW, MC_UNFOLLOW, MC_BLOCK, MC_UNBLOCK, MC_FAVOURITE, MC_UNFAVOURITE, MC_PIN, MC_UNPIN, MC_ACCOUNT_MUTE, MC_ACCOUNT_UNMUTE, MC_STATUS_MUTE, MC_STATUS_UNMUTE, MC_BOOST, MC_UNBOOST, MC_LIST_CREATE, MC_LIST_DELETE, MC_LIST_ADD_ACCOUNT, MC_LIST_REMOVE_ACCOUNT, MC_FILTER_CREATE, MC_FILTER_DELETE, } mastodon_command_type_t; struct mastodon_log_data; #define MASTODON_MAX_UNDO 10 struct mastodon_data { char* user; /* to be used when parsing commands */ struct oauth2_service *oauth2_service; char *oauth2_access_token; gpointer home_timeline_obj; /* of mastodon_list */ gpointer notifications_obj; /* of mastodon_list */ gpointer status_obj; /* of mastodon_status */ gpointer context_before_obj; /* of mastodon_list */ gpointer context_after_obj; /* of mastodon_list */ GSList *streams; /* of struct http_request */ struct groupchat *timeline_gc; guint64 seen_id; /* For deduplication */ mastodon_flags_t flags; GSList *filters; /* of struct mastodon_filter */ guint64 last_id; /* Information about our last status posted */ mastodon_visibility_t last_visibility; char *last_spoiler_text; GSList *mentions; mastodon_visibility_t visibility; /* visibility for the next status */ char *spoiler_text; /* CW for the next post */ mastodon_undo_t undo_type; /* for the current command */ char *undo[MASTODON_MAX_UNDO]; /* a small stack of undo statements */ char *redo[MASTODON_MAX_UNDO]; /* a small stack of redo statements */ int first_undo; /* index of the latest item in the undo and redo stacks */ int current_undo; /* index of the current item in the undo and redo stacks */ /* for the more command */ char *next_url; mastodon_more_t more_type; /* set base_url */ gboolean url_ssl; int url_port; char *url_host; char *name; /* Used to generate contact + channel name. */ /* set show_ids */ struct mastodon_log_data *log; int log_id; }; struct mastodon_user_data { guint64 account_id; guint64 last_id; /* last status id (in case we reply to it) */ time_t last_time; /* when was this last status sent (if we maybe reply) */ guint64 last_direct_id; /* last direct status id (in case we reply to it) */ time_t last_direct_time; /* when was this last direct status sent (if we reply) */ mastodon_visibility_t visibility; /* what visibility did it have so can use it in our reply */ GSList *mentions; /* what accounts did it mention so we can mention them in our reply, too */ char *spoiler_text; /* what CW did it use so we can keep it in our reply */ GSList *lists; /* list membership of this account */ }; #define MASTODON_LOG_LENGTH 256 struct mastodon_log_data { guint64 id; /* DANGER: bu can be a dead pointer. Check it first. * mastodon_message_id_from_command_arg() will do this. */ struct bee_user *bu; mastodon_visibility_t visibility; GSList *mentions; char *spoiler_text; }; /** * This has the same function as the msn_connections GSList. We use this to * make sure the connection is still alive in callbacks before we do anything * else. */ extern GSList *mastodon_connections; /** * Evil hack: Fake bee_user which will always point at the local user. * Sometimes used as a return value by mastodon_message_id_from_command_arg. * NOT thread safe but don't you dare to even think of ever making BitlBee * threaded. :-) */ extern bee_user_t mastodon_log_local_user; struct http_request; char *mastodon_parse_error(struct http_request *req); void mastodon_log(struct im_connection *ic, char *format, ...); void oauth2_init(struct im_connection *ic); struct groupchat *mastodon_groupchat_init(struct im_connection *ic); void mastodon_do(struct im_connection *ic, char *redo, char *undo); void mastodon_do_update(struct im_connection *ic, char *to); ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/src/rot13.c������������������������������������������������������������������0000664�0000000�0000000�00000000334�13634420343�0016655�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������void rot13(char *s) { for (; *s != 0; s++) { if ((*s >= 'A' && *s <= 'M') || (*s >= 'a' && *s <= 'm')) { *s = *s + 13; } else if ((*s >= 'N' && *s <= 'Z') || (*s >= 'n' && *s <= 'z')) { *s = *s - 13; } } } ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������bitlbee-mastodon-1.4.4/src/rot13.h������������������������������������������������������������������0000664�0000000�0000000�00000000025�13634420343�0016657�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������void rot13(char *s); �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������