diff options
Diffstat (limited to 'weechat/lua')
l--------- | weechat/lua/autoload/emoji.lua | 1 | ||||
l--------- | weechat/lua/autoload/matrix.lua | 1 | ||||
-rw-r--r-- | weechat/lua/emoji.lua | 207 | ||||
-rw-r--r-- | weechat/lua/matrix.lua | 3354 |
4 files changed, 3563 insertions, 0 deletions
diff --git a/weechat/lua/autoload/emoji.lua b/weechat/lua/autoload/emoji.lua new file mode 120000 index 0000000..91557c7 --- /dev/null +++ b/weechat/lua/autoload/emoji.lua @@ -0,0 +1 @@ +../emoji.lua
\ No newline at end of file diff --git a/weechat/lua/autoload/matrix.lua b/weechat/lua/autoload/matrix.lua new file mode 120000 index 0000000..d91a9aa --- /dev/null +++ b/weechat/lua/autoload/matrix.lua @@ -0,0 +1 @@ +/home/neodarz/.weechat/lua/matrix.lua
\ No newline at end of file diff --git a/weechat/lua/emoji.lua b/weechat/lua/emoji.lua new file mode 100644 index 0000000..75d4b44 --- /dev/null +++ b/weechat/lua/emoji.lua @@ -0,0 +1,207 @@ +-- Copyright 2016 xt <xt@bash.no> +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License +-- along with this program. If not, see <http://www.gnu.org/licenses/>. +-- + + +--[[ + + +Emoji output/input shortcode helper for WeeChat. + +Usage: + Type :emojiname: from http://www.emoji-cheat-sheet.com/ or :partial match<tab> + + + Changelog: + version 3, 2016-11-04, xt + * add a slack specific shortcode + version 2, 2016-10-31, xt + * some cleanup + * ability replace incoming + version 1, 2016-02-29, xt + * initial version + + +EMOJI CONVERSION SCRIPT: +import json + +e = json.loads(open('emoji.json').read()) +print "emoji = {" +emojis = [] +for k, v in e.items(): + shortname = v['shortname'].strip(':') + ubytes = [] + cps = v['unicode'].split('-') + for c in cps: + ubytes.append(unichr(int(c, 16))) + if shortname in ['end', 'repeat']: + shortname = '["%s"]' %shortname + elif '-' in shortname: + shortname = '["%s"]' %shortname + elif shortname[0].isdigit(): + shortname = '["%s"]' %shortname + else: + try: + shortname = int(shortname) + shortname = '["%s"]' %shortname + except: + pass + emojis.append((shortname+'="'+u"".join(ubytes)+'",').encode('UTF-8')) +print "".join(emojis) +print "}" +--]] + +local SCRIPT_NAME = "emoji" +local SCRIPT_AUTHOR = "xt <xt@bash.no>" +local SCRIPT_VERSION = "3" +local SCRIPT_LICENSE = "GPL3" +local SCRIPT_DESC = "Emoji output helper" + + +-- luacheck: ignore weechat +local w = weechat +local emoji = { +four="4โฃ",kiss_ww="๐ฉโค๐๐ฉ",maple_leaf="๐",waxing_gibbous_moon="๐",bike="๐ฒ",recycle="โป",family_mwgb="๐จ๐ฉ๐ง๐ฆ",flag_dk="๐ฉ๐ฐ",thought_balloon="๐ญ",oncoming_automobile="๐",guardsman_tone5="๐๐ฟ",tickets="๐",school="๐ซ",house_abandoned="๐",blue_book="๐",video_game="๐ฎ",triumph="๐ค",suspension_railway="๐",umbrella="โ",levitate="๐ด",cactus="๐ต",monorail="๐",stars="๐ ",new="๐",herb="๐ฟ",pouting_cat="๐พ",blue_heart="๐",["100"]="๐ฏ",leaves="๐",family_mwbb="๐จ๐ฉ๐ฆ๐ฆ",information_desk_person_tone2="๐๐ผ",dragon_face="๐ฒ",track_next="โญ",cloud_snow="๐จ",flag_jp="๐ฏ๐ต",children_crossing="๐ธ",information_desk_person_tone1="๐๐ป",arrow_up_down="โ",mount_fuji="๐ป",massage_tone1="๐๐ป",flag_mq="๐ฒ๐ถ",massage_tone3="๐๐ฝ",massage_tone2="๐๐ผ",massage_tone5="๐๐ฟ",flag_je="๐ฏ๐ช",flag_jm="๐ฏ๐ฒ",flag_jo="๐ฏ๐ด",red_car="๐",hospital="๐ฅ",red_circle="๐ด",princess="๐ธ",tm="โข",curly_loop="โฐ",boy_tone5="๐ฆ๐ฟ",pouch="๐",boy_tone3="๐ฆ๐ฝ",boy_tone1="๐ฆ๐ป",izakaya_lantern="๐ฎ",fist_tone5="โ๐ฟ",fist_tone4="โ๐พ",fist_tone1="โ๐ป",fist_tone3="โ๐ฝ",fist_tone2="โ๐ผ",arrow_lower_left="โ",game_die="๐ฒ",pushpin="๐",dividers="๐",dolphin="๐ฌ",night_with_stars="๐",cruise_ship="๐ณ",white_medium_small_square="โฝ",kissing_closed_eyes="๐",earth_americas="๐",["end"]="๐",mouse="๐ญ",rewind="โช",beach="๐",pizza="๐",briefcase="๐ผ",customs="๐",heartpulse="๐",sparkler="๐",sparkles="โจ",hand_splayed_tone1="๐๐ป",snowman2="โ",tulip="๐ท",speaking_head="๐ฃ",ambulance="๐",office="๐ข",clapper="๐ฌ",keyboard="โจ",japan="๐พ",post_office="๐ฃ",dizzy_face="๐ต",imp="๐ฟ",flag_ve="๐ป๐ช",coffee="โ",flag_vg="๐ป๐ฌ",flag_va="๐ป๐ฆ",flag_vc="๐ป๐จ",flag_vn="๐ป๐ณ",flag_vi="๐ป๐ฎ",open_mouth="๐ฎ",flag_vu="๐ป๐บ",page_with_curl="๐",bank="๐ฆ",bread="๐",oncoming_police_car="๐",capricorn="โ",point_left="๐",tokyo_tower="๐ผ",fishing_pole_and_fish="๐ฃ",thumbsdown="๐",telescope="๐ญ",spider="๐ท",u7121="๐",camera_with_flash="๐ธ",lifter="๐",sweet_potato="๐ ",lock_with_ink_pen="๐",ok_woman_tone2="๐๐ผ",ok_woman_tone3="๐๐ฝ",smirk="๐",baggage_claim="๐",cherry_blossom="๐ธ",sparkle="โ",zap="โก",construction_site="๐",dancers="๐ฏ",flower_playing_cards="๐ด",hatching_chick="๐ฃ",free="๐",bullettrain_side="๐",poultry_leg="๐",grapes="๐",smirk_cat="๐ผ",lollipop="๐ญ",water_buffalo="๐",black_medium_small_square="โพ",atm="๐ง",gift_heart="๐",older_woman_tone5="๐ต๐ฟ",older_woman_tone4="๐ต๐พ",older_woman_tone1="๐ต๐ป",older_woman_tone3="๐ต๐ฝ",older_woman_tone2="๐ต๐ผ",scissors="โ",woman_tone2="๐ฉ๐ผ",basketball="๐",hammer_pick="โ",top="๐",clock630="๐ก",raising_hand_tone5="๐๐ฟ",railway_track="๐ค",nail_care="๐
",crossed_flags="๐",minibus="๐",white_sun_cloud="๐ฅ",shower="๐ฟ",smile_cat="๐ธ",dog2="๐",loud_sound="๐",kaaba="๐",runner="๐",ram="๐",writing_hand="โ",rat="๐",rice_scene="๐",milky_way="๐",vulcan_tone5="๐๐ฟ",necktie="๐",kissing_cat="๐ฝ",snowflake="โ",paintbrush="๐",crystal_ball="๐ฎ",mountain_bicyclist_tone4="๐ต๐พ",mountain_bicyclist_tone3="๐ต๐ฝ",mountain_bicyclist_tone2="๐ต๐ผ",mountain_bicyclist_tone1="๐ต๐ป",koko="๐",flag_it="๐ฎ๐น",flag_iq="๐ฎ๐ถ",flag_is="๐ฎ๐ธ",flag_ir="๐ฎ๐ท",flag_im="๐ฎ๐ฒ",flag_il="๐ฎ๐ฑ",flag_io="๐ฎ๐ด",flag_in="๐ฎ๐ณ",flag_ie="๐ฎ๐ช",flag_id="๐ฎ๐ฉ",flag_ic="๐ฎ๐จ",ballot_box_with_check="โ",mountain_bicyclist_tone5="๐ต๐ฟ",metal="๐ค",dog="๐ถ",pineapple="๐",no_good_tone3="๐
๐ฝ",no_good_tone2="๐
๐ผ",no_good_tone1="๐
๐ป",scream="๐ฑ",no_good_tone5="๐
๐ฟ",no_good_tone4="๐
๐พ",flag_ua="๐บ๐ฆ",bomb="๐ฃ",flag_ug="๐บ๐ฌ",flag_um="๐บ๐ฒ",flag_us="๐บ๐ธ",construction_worker_tone1="๐ท๐ป",radio="๐ป",flag_uy="๐บ๐พ",flag_uz="๐บ๐ฟ",person_with_blond_hair_tone1="๐ฑ๐ป",cupid="๐",mens="๐น",rice="๐",point_right_tone1="๐๐ป",point_right_tone3="๐๐ฝ",point_right_tone2="๐๐ผ",sunglasses="๐",point_right_tone4="๐๐พ",watch="โ",frowning="๐ฆ",watermelon="๐",wedding="๐",person_frowning_tone4="๐๐พ",person_frowning_tone5="๐๐ฟ",person_frowning_tone2="๐๐ผ",person_frowning_tone3="๐๐ฝ",person_frowning_tone1="๐๐ป",flag_gw="๐ฌ๐ผ",flag_gu="๐ฌ๐บ",flag_gt="๐ฌ๐น",flag_gs="๐ฌ๐ธ",flag_gr="๐ฌ๐ท",flag_gq="๐ฌ๐ถ",flag_gp="๐ฌ๐ต",flag_gy="๐ฌ๐พ",flag_gg="๐ฌ๐ฌ",flag_gf="๐ฌ๐ซ",microscope="๐ฌ",flag_gd="๐ฌ๐ฉ",flag_gb="๐ฌ๐ง",flag_ga="๐ฌ๐ฆ",flag_gn="๐ฌ๐ณ",flag_gm="๐ฌ๐ฒ",flag_gl="๐ฌ๐ฑ",japanese_ogre="๐น",flag_gi="๐ฌ๐ฎ",flag_gh="๐ฌ๐ญ",man_with_turban="๐ณ",star_and_crescent="โช",writing_hand_tone3="โ๐ฝ",dromedary_camel="๐ช",hash="#โฃ",hammer="๐จ",hourglass="โ",postbox="๐ฎ",writing_hand_tone5="โ๐ฟ",writing_hand_tone4="โ๐พ",wc="๐พ",aquarius="โ",couple_with_heart="๐",ok_woman="๐",raised_hands_tone4="๐๐พ",cop="๐ฎ",raised_hands_tone1="๐๐ป",cow="๐ฎ",raised_hands_tone3="๐๐ฝ",white_large_square="โฌ",pig_nose="๐ฝ",ice_skate="โธ",hotsprings="โจ",tone5="๐ฟ",three="3โฃ",beer="๐บ",stadium="๐",airplane_departure="๐ซ",heavy_division_sign="โ",flag_black="๐ด",mushroom="๐",record_button="โบ",vulcan="๐",dash="๐จ",wind_chime="๐",anchor="โ",seven="7โฃ",flag_hr="๐ญ๐ท",roller_coaster="๐ข",pen_ballpoint="๐",sushi="๐ฃ",flag_ht="๐ญ๐น",flag_hu="๐ญ๐บ",flag_hk="๐ญ๐ฐ",dizzy="๐ซ",flag_hn="๐ญ๐ณ",flag_hm="๐ญ๐ฒ",arrow_forward="โถ",violin="๐ป",orthodox_cross="โฆ",id="๐",heart_decoration="๐",first_quarter_moon="๐",satellite="๐ก",tone3="๐ฝ",christmas_tree="๐",unicorn="๐ฆ",broken_heart="๐",ocean="๐",hearts="โฅ",snowman="โ",person_with_blond_hair_tone4="๐ฑ๐พ",person_with_blond_hair_tone5="๐ฑ๐ฟ",person_with_blond_hair_tone2="๐ฑ๐ผ",person_with_blond_hair_tone3="๐ฑ๐ฝ",yen="๐ด",straight_ruler="๐",sleepy="๐ช",green_apple="๐",white_medium_square="โป",flag_fr="๐ซ๐ท",grey_exclamation="โ",innocent="๐",flag_fm="๐ซ๐ฒ",flag_fo="๐ซ๐ด",flag_fi="๐ซ๐ฎ",flag_fj="๐ซ๐ฏ",flag_fk="๐ซ๐ฐ",menorah="๐",yin_yang="โฏ",clock130="๐",gift="๐",prayer_beads="๐ฟ",stuck_out_tongue="๐",om_symbol="๐",city_dusk="๐",massage_tone4="๐๐พ",couple_ww="๐ฉโค๐ฉ",crown="๐",sparkling_heart="๐",clubs="โฃ",person_with_pouting_face="๐",newspaper2="๐",fog="๐ซ",dango="๐ก",large_orange_diamond="๐ถ",flag_tn="๐น๐ณ",flag_to="๐น๐ด",point_up="โ",flag_tm="๐น๐ฒ",flag_tj="๐น๐ฏ",flag_tk="๐น๐ฐ",flag_th="๐น๐ญ",flag_tf="๐น๐ซ",flag_tg="๐น๐ฌ",corn="๐ฝ",flag_tc="๐น๐จ",flag_ta="๐น๐ฆ",flag_tz="๐น๐ฟ",flag_tv="๐น๐ป",flag_tw="๐น๐ผ",flag_tt="๐น๐น",flag_tr="๐น๐ท",eight_spoked_asterisk="โณ",trophy="๐",black_small_square="โช",o="โญ",no_bell="๐",curry="๐",alembic="โ",sob="๐ญ",waxing_crescent_moon="๐",tiger2="๐
",two="2โฃ",sos="๐",compression="๐",heavy_multiplication_x="โ",tennis="๐พ",fireworks="๐",skull_crossbones="โ ",astonished="๐ฒ",congratulations="ใ",grey_question="โ",arrow_upper_left="โ",arrow_double_up="โซ",triangular_flag_on_post="๐ฉ",gemini="โ",door="๐ช",ship="๐ข",point_down_tone3="๐๐ฝ",point_down_tone4="๐๐พ",point_down_tone5="๐๐ฟ",movie_camera="๐ฅ",ng="๐",couple_mm="๐จโค๐จ",football="๐",asterisk="*โฃ",taurus="โ",articulated_lorry="๐",police_car="๐",flushed="๐ณ",spades="โ ",cloud_lightning="๐ฉ",wine_glass="๐ท",clock830="๐ฃ",punch_tone2="๐๐ผ",punch_tone3="๐๐ฝ",punch_tone1="๐๐ป",department_store="๐ฌ",punch_tone4="๐๐พ",punch_tone5="๐๐ฟ",crocodile="๐",white_square_button="๐ณ",hole="๐ณ",boy_tone2="๐ฆ๐ผ",mountain_cableway="๐ ",melon="๐",persevere="๐ฃ",trident="๐ฑ",head_bandage="๐ค",u7a7a="๐ณ",cool="๐",high_brightness="๐",deciduous_tree="๐ณ",white_flower="๐ฎ",gun="๐ซ",flag_sk="๐ธ๐ฐ",flag_sj="๐ธ๐ฏ",flag_si="๐ธ๐ฎ",flag_sh="๐ธ๐ญ",flag_so="๐ธ๐ด",flag_sn="๐ธ๐ณ",flag_sm="๐ธ๐ฒ",flag_sl="๐ธ๐ฑ",flag_sc="๐ธ๐จ",flag_sb="๐ธ๐ง",flag_sa="๐ธ๐ฆ",flag_sg="๐ธ๐ฌ",flag_tl="๐น๐ฑ",flag_se="๐ธ๐ช",arrow_left="โฌ
",flag_sz="๐ธ๐ฟ",flag_sy="๐ธ๐พ",small_orange_diamond="๐ธ",flag_ss="๐ธ๐ธ",flag_sr="๐ธ๐ท",flag_sv="๐ธ๐ป",flag_st="๐ธ๐น",file_folder="๐",flag_td="๐น๐ฉ",["1234"]="๐ข",smiling_imp="๐",surfer_tone2="๐๐ผ",surfer_tone3="๐๐ฝ",surfer_tone4="๐๐พ",surfer_tone5="๐๐ฟ",amphora="๐บ",baseball="โพ",boy="๐ฆ",flag_es="๐ช๐ธ",raised_hands="๐",flag_eu="๐ช๐บ",flag_et="๐ช๐น",heavy_plus_sign="โ",bow="๐",flag_ea="๐ช๐ฆ",flag_ec="๐ช๐จ",flag_ee="๐ช๐ช",light_rail="๐",flag_eg="๐ช๐ฌ",flag_eh="๐ช๐ญ",massage="๐",man_with_gua_pi_mao_tone4="๐ฒ๐พ",man_with_gua_pi_mao_tone3="๐ฒ๐ฝ",outbox_tray="๐ค",clock330="๐",projector="๐ฝ",sake="๐ถ",confounded="๐",angry="๐ ",iphone="๐ฑ",sweat_smile="๐
",aries="โ",ear_of_rice="๐พ",mouse2="๐",bicyclist_tone4="๐ด๐พ",bicyclist_tone5="๐ด๐ฟ",guardsman="๐",bicyclist_tone1="๐ด๐ป",bicyclist_tone2="๐ด๐ผ",bicyclist_tone3="๐ด๐ฝ",envelope="โ",money_with_wings="๐ธ",beers="๐ป",heart_exclamation="โฃ",notepad_spiral="๐",cat="๐ฑ",running_shirt_with_sash="๐ฝ",ferry="โด",spy="๐ต",chart_with_upwards_trend="๐",green_heart="๐",confused="๐",angel_tone4="๐ผ๐พ",scorpius="โ",sailboat="โต",elephant="๐",map="๐บ",disappointed_relieved="๐ฅ",flag_xk="๐ฝ๐ฐ",motorway="๐ฃ",sun_with_face="๐",birthday="๐",mag="๐",date="๐
",dove="๐",man="๐จ",octopus="๐",wheelchair="โฟ",truck="๐",sa="๐",shield="๐ก",haircut="๐",last_quarter_moon_with_face="๐",rosette="๐ต",currency_exchange="๐ฑ",mailbox_with_no_mail="๐ญ",bath="๐",clock930="๐ค",bowling="๐ณ",turtle="๐ข",pause_button="โธ",construction_worker="๐ท",unlock="๐",anger_right="๐ฏ",beetle="๐",girl="๐ง",sunrise="๐
",exclamation="โ",flag_dz="๐ฉ๐ฟ",family_mmgg="๐จ๐จ๐ง๐ง",factory="๐ญ",flag_do="๐ฉ๐ด",flag_dm="๐ฉ๐ฒ",flag_dj="๐ฉ๐ฏ",mouse_three_button="๐ฑ",flag_dg="๐ฉ๐ฌ",flag_de="๐ฉ๐ช",star_of_david="โก",reminder_ribbon="๐",grimacing="๐ฌ",thumbsup_tone3="๐๐ฝ",thumbsup_tone2="๐๐ผ",thumbsup_tone1="๐๐ป",musical_note="๐ต",thumbsup_tone5="๐๐ฟ",thumbsup_tone4="๐๐พ",high_heel="๐ ",green_book="๐",headphones="๐ง",flag_aw="๐ฆ๐ผ",stop_button="โน",yum="๐",flag_aq="๐ฆ๐ถ",warning="โ ",cheese="๐ง",ophiuchus="โ",revolving_hearts="๐",one="1โฃ",ring="๐",point_right="๐",sheep="๐",bookmark="๐",spider_web="๐ธ",eyes="๐",flag_ro="๐ท๐ด",flag_re="๐ท๐ช",flag_rs="๐ท๐ธ",sweat_drops="๐ฆ",flag_ru="๐ท๐บ",flag_rw="๐ท๐ผ",middle_finger="๐",race_car="๐",evergreen_tree="๐ฒ",biohazard="โฃ",girl_tone3="๐ง๐ฝ",scream_cat="๐",computer="๐ป",hourglass_flowing_sand="โณ",flag_lb="๐ฑ๐ง",tophat="๐ฉ",clock1230="๐ง",tractor="๐",u6709="๐ถ",u6708="๐ท",crying_cat_face="๐ฟ",angel="๐ผ",ant="๐",information_desk_person="๐",anger="๐ข",mailbox_with_mail="๐ฌ",pencil2="โ",wink="๐",thermometer="๐ก",relaxed="โบ",printer="๐จ",credit_card="๐ณ",checkered_flag="๐",family_mmg="๐จ๐จ๐ง",pager="๐",family_mmb="๐จ๐จ๐ฆ",radioactive="โข",fried_shrimp="๐ค",link="๐",walking="๐ถ",city_sunset="๐",shopping_bags="๐",hockey="๐",arrow_up="โฌ",gem="๐",negative_squared_cross_mark="โ",worried="๐",walking_tone5="๐ถ๐ฟ",walking_tone1="๐ถ๐ป",hear_no_evil="๐",convenience_store="๐ช",seat="๐บ",girl_tone1="๐ง๐ป",cloud_rain="๐ง",girl_tone2="๐ง๐ผ",girl_tone5="๐ง๐ฟ",girl_tone4="๐ง๐พ",parking="๐
ฟ",pisces="โ",calendar="๐",loudspeaker="๐ข",camping="๐",bicyclist="๐ด",label="๐ท",diamonds="โฆ",older_man_tone1="๐ด๐ป",older_man_tone3="๐ด๐ฝ",older_man_tone2="๐ด๐ผ",older_man_tone5="๐ด๐ฟ",older_man_tone4="๐ด๐พ",microphone2="๐",raising_hand="๐",hot_pepper="๐ถ",guitar="๐ธ",tropical_drink="๐น",upside_down="๐",restroom="๐ป",pen_fountain="๐",comet="โ",cancer="โ",jeans="๐",flag_qa="๐ถ๐ฆ",boar="๐",turkey="๐ฆ",person_with_blond_hair="๐ฑ",oden="๐ข",stuck_out_tongue_closed_eyes="๐",helicopter="๐",control_knobs="๐",performing_arts="๐ญ",tiger="๐ฏ",foggy="๐",sound="๐",flag_cz="๐จ๐ฟ",flag_cy="๐จ๐พ",flag_cx="๐จ๐ฝ",speech_balloon="๐ฌ",seedling="๐ฑ",flag_cr="๐จ๐ท",envelope_with_arrow="๐ฉ",flag_cp="๐จ๐ต",flag_cw="๐จ๐ผ",flag_cv="๐จ๐ป",flag_cu="๐จ๐บ",flag_ck="๐จ๐ฐ",flag_ci="๐จ๐ฎ",flag_ch="๐จ๐ญ",flag_co="๐จ๐ด",flag_cn="๐จ๐ณ",flag_cm="๐จ๐ฒ",u5408="๐ด",flag_cc="๐จ๐จ",flag_ca="๐จ๐ฆ",flag_cg="๐จ๐ฌ",flag_cf="๐จ๐ซ",flag_cd="๐จ๐ฉ",purse="๐",telephone="โ",sleeping="๐ด",point_down_tone1="๐๐ป",frowning2="โน",point_down_tone2="๐๐ผ",muscle_tone4="๐ช๐พ",muscle_tone5="๐ช๐ฟ",synagogue="๐",muscle_tone1="๐ช๐ป",muscle_tone2="๐ช๐ผ",muscle_tone3="๐ช๐ฝ",clap_tone5="๐๐ฟ",clap_tone4="๐๐พ",clap_tone1="๐๐ป",train2="๐",clap_tone2="๐๐ผ",oil="๐ข",diamond_shape_with_a_dot_inside="๐ ",barber="๐",metal_tone3="๐ค๐ฝ",ice_cream="๐จ",rowboat_tone4="๐ฃ๐พ",burrito="๐ฏ",metal_tone1="๐ค๐ป",joystick="๐น",rowboat_tone1="๐ฃ๐ป",taxi="๐",u7533="๐ธ",racehorse="๐",snowboarder="๐",thinking="๐ค",wave_tone1="๐๐ป",wave_tone2="๐๐ผ",wave_tone3="๐๐ฝ",wave_tone4="๐๐พ",wave_tone5="๐๐ฟ",desktop="๐ฅ",stopwatch="โฑ",pill="๐",skier="โท",orange_book="๐",dart="๐ฏ",disappointed="๐",grin="๐",place_of_worship="๐",japanese_goblin="๐บ",arrows_counterclockwise="๐",laughing="๐",clap="๐",left_right_arrow="โ",japanese_castle="๐ฏ",nail_care_tone4="๐
๐พ",nail_care_tone5="๐
๐ฟ",nail_care_tone2="๐
๐ผ",nail_care_tone3="๐
๐ฝ",nail_care_tone1="๐
๐ป",raised_hand_tone4="โ๐พ",raised_hand_tone5="โ๐ฟ",raised_hand_tone1="โ๐ป",raised_hand_tone2="โ๐ผ",raised_hand_tone3="โ๐ฝ",point_left_tone3="๐๐ฝ",point_left_tone2="๐๐ผ",tanabata_tree="๐",point_left_tone5="๐๐ฟ",point_left_tone4="๐๐พ",o2="๐
พ",knife="๐ช",volcano="๐",kissing_heart="๐",on="๐",ok="๐",package="๐ฆ",island="๐",arrow_right="โก",chart_with_downwards_trend="๐",haircut_tone3="๐๐ฝ",wolf="๐บ",ox="๐",dagger="๐ก",full_moon_with_face="๐",syringe="๐",flag_by="๐ง๐พ",flag_bz="๐ง๐ฟ",flag_bq="๐ง๐ถ",flag_br="๐ง๐ท",flag_bs="๐ง๐ธ",flag_bt="๐ง๐น",flag_bv="๐ง๐ป",flag_bw="๐ง๐ผ",flag_bh="๐ง๐ญ",flag_bi="๐ง๐ฎ",flag_bj="๐ง๐ฏ",flag_bl="๐ง๐ฑ",flag_bm="๐ง๐ฒ",flag_bn="๐ง๐ณ",flag_bo="๐ง๐ด",flag_ba="๐ง๐ฆ",flag_bb="๐ง๐ง",flag_bd="๐ง๐ฉ",flag_be="๐ง๐ช",flag_bf="๐ง๐ซ",flag_bg="๐ง๐ฌ",satellite_orbital="๐ฐ",radio_button="๐",arrow_heading_down="โคต",rage="๐ก",whale2="๐",vhs="๐ผ",hand_splayed_tone3="๐๐ฝ",strawberry="๐",["non-potable_water"]="๐ฑ",hand_splayed_tone5="๐๐ฟ",star2="๐",toilet="๐ฝ",ab="๐",cinema="๐ฆ",floppy_disk="๐พ",princess_tone4="๐ธ๐พ",princess_tone5="๐ธ๐ฟ",princess_tone2="๐ธ๐ผ",nerd="๐ค",telephone_receiver="๐",princess_tone1="๐ธ๐ป",arrow_double_down="โฌ",clock1030="๐ฅ",flag_pr="๐ต๐ท",flag_ps="๐ต๐ธ",poop="๐ฉ",flag_pw="๐ต๐ผ",flag_pt="๐ต๐น",flag_py="๐ต๐พ",pear="๐",m="โ",flag_pa="๐ต๐ฆ",flag_pf="๐ต๐ซ",flag_pg="๐ต๐ฌ",flag_pe="๐ต๐ช",flag_pk="๐ต๐ฐ",flag_ph="๐ต๐ญ",flag_pn="๐ต๐ณ",flag_pl="๐ต๐ฑ",flag_pm="๐ต๐ฒ",mask="๐ท",hushed="๐ฏ",sunrise_over_mountains="๐",partly_sunny="โ
",dollar="๐ต",helmet_with_cross="โ",smoking="๐ฌ",no_bicycles="๐ณ",man_with_gua_pi_mao="๐ฒ",tv="๐บ",open_hands="๐",rotating_light="๐จ",information_desk_person_tone4="๐๐พ",information_desk_person_tone5="๐๐ฟ",part_alternation_mark="ใฝ",pray_tone5="๐๐ฟ",pray_tone4="๐๐พ",pray_tone3="๐๐ฝ",pray_tone2="๐๐ผ",pray_tone1="๐๐ป",smile="๐",large_blue_circle="๐ต",man_tone4="๐จ๐พ",man_tone5="๐จ๐ฟ",fax="๐ ",woman="๐ฉ",man_tone1="๐จ๐ป",man_tone2="๐จ๐ผ",man_tone3="๐จ๐ฝ",eye_in_speech_bubble="๐๐จ",blowfish="๐ก",card_box="๐",ticket="๐ซ",ramen="๐",twisted_rightwards_arrows="๐",swimmer_tone4="๐๐พ",swimmer_tone5="๐๐ฟ",swimmer_tone1="๐๐ป",swimmer_tone2="๐๐ผ",swimmer_tone3="๐๐ฝ",saxophone="๐ท",bath_tone1="๐๐ป",notebook_with_decorative_cover="๐",bath_tone3="๐๐ฝ",ten="๐",raising_hand_tone4="๐๐พ",tea="๐ต",raising_hand_tone1="๐๐ป",raising_hand_tone2="๐๐ผ",raising_hand_tone3="๐๐ฝ",zero="0โฃ",ribbon="๐",santa_tone1="๐
๐ป",abc="๐ค",clock="๐ฐ",purple_heart="๐",bow_tone1="๐๐ป",no_smoking="๐ญ",flag_cl="๐จ๐ฑ",surfer="๐",newspaper="๐ฐ",busstop="๐",new_moon="๐",traffic_light="๐ฅ",thumbsup="๐",no_entry="โ",name_badge="๐",classical_building="๐",hamster="๐น",pick="โ",two_women_holding_hands="๐ญ",family_mmbb="๐จ๐จ๐ฆ๐ฆ",family="๐ช",rice_cracker="๐",wind_blowing_face="๐ฌ",inbox_tray="๐ฅ",tired_face="๐ซ",carousel_horse="๐ ",eye="๐",poodle="๐ฉ",chestnut="๐ฐ",slight_smile="๐",mailbox_closed="๐ช",cloud_tornado="๐ช",jack_o_lantern="๐",lifter_tone3="๐๐ฝ",lifter_tone2="๐๐ผ",lifter_tone1="๐๐ป",lifter_tone5="๐๐ฟ",lifter_tone4="๐๐พ",nine="9โฃ",chocolate_bar="๐ซ",v="โ",man_with_turban_tone4="๐ณ๐พ",man_with_turban_tone5="๐ณ๐ฟ",man_with_turban_tone2="๐ณ๐ผ",man_with_turban_tone3="๐ณ๐ฝ",man_with_turban_tone1="๐ณ๐ป",family_wwbb="๐ฉ๐ฉ๐ฆ๐ฆ",hamburger="๐",accept="๐",airplane="โ",dress="๐",speedboat="๐ค",ledger="๐",goat="๐",flag_ae="๐ฆ๐ช",flag_ad="๐ฆ๐ฉ",flag_ag="๐ฆ๐ฌ",flag_af="๐ฆ๐ซ",flag_ac="๐ฆ๐จ",flag_am="๐ฆ๐ฒ",flag_al="๐ฆ๐ฑ",flag_ao="๐ฆ๐ด",flag_ai="๐ฆ๐ฎ",flag_au="๐ฆ๐บ",flag_at="๐ฆ๐น",fork_and_knife="๐ด",fast_forward="โฉ",flag_as="๐ฆ๐ธ",flag_ar="๐ฆ๐ท",cow2="๐",flag_ax="๐ฆ๐ฝ",flag_az="๐ฆ๐ฟ",a="๐
ฐ",volleyball="๐",dragon="๐",wrench="๐ง",point_up_2="๐",egg="๐ณ",small_red_triangle="๐บ",soon="๐",bow_tone4="๐๐พ",joy_cat="๐น",pray="๐",dark_sunglasses="๐ถ",rugby_football="๐",soccer="โฝ",dolls="๐",monkey_face="๐ต",clap_tone3="๐๐ฝ",bar_chart="๐",european_castle="๐ฐ",military_medal="๐",frame_photo="๐ผ",rice_ball="๐",trolleybus="๐",older_woman="๐ต",information_source="โน",postal_horn="๐ฏ",house="๐ ",fish="๐",bride_with_veil="๐ฐ",fist="โ",lipstick="๐",fountain="โฒ",cyclone="๐",thumbsdown_tone2="๐๐ผ",thumbsdown_tone3="๐๐ฝ",thumbsdown_tone1="๐๐ป",thumbsdown_tone4="๐๐พ",thumbsdown_tone5="๐๐ฟ",cookie="๐ช",heartbeat="๐",blush="๐",fire_engine="๐",feet="๐พ",horse="๐ด",blossom="๐ผ",crossed_swords="โ",station="๐",clock730="๐ข",banana="๐",relieved="๐",hotel="๐จ",park="๐",aerial_tramway="๐ก",flag_sd="๐ธ๐ฉ",panda_face="๐ผ",b="๐
ฑ",flag_sx="๐ธ๐ฝ",six_pointed_star="๐ฏ",shaved_ice="๐ง",chipmunk="๐ฟ",mountain="โฐ",koala="๐จ",white_small_square="โซ",open_hands_tone2="๐๐ผ",open_hands_tone3="๐๐ฝ",u55b6="๐บ",open_hands_tone1="๐๐ป",open_hands_tone4="๐๐พ",open_hands_tone5="๐๐ฟ",baby_tone5="๐ถ๐ฟ",baby_tone4="๐ถ๐พ",baby_tone3="๐ถ๐ฝ",baby_tone2="๐ถ๐ผ",baby_tone1="๐ถ๐ป",chart="๐น",beach_umbrella="โฑ",basketball_player_tone5="โน๐ฟ",basketball_player_tone4="โน๐พ",basketball_player_tone1="โน๐ป",basketball_player_tone3="โน๐ฝ",basketball_player_tone2="โน๐ผ",mans_shoe="๐",shinto_shrine="โฉ",ideograph_advantage="๐",airplane_arriving="๐ฌ",golf="โณ",minidisc="๐ฝ",hugging="๐ค",crayon="๐",point_down="๐",copyright="ยฉ",person_with_pouting_face_tone2="๐๐ผ",person_with_pouting_face_tone3="๐๐ฝ",person_with_pouting_face_tone1="๐๐ป",person_with_pouting_face_tone4="๐๐พ",person_with_pouting_face_tone5="๐๐ฟ",busts_in_silhouette="๐ฅ",alarm_clock="โฐ",couplekiss="๐",circus_tent="๐ช",sunny="โ",incoming_envelope="๐จ",yellow_heart="๐",cry="๐ข",x="โ",arrow_up_small="๐ผ",art="๐จ",surfer_tone1="๐๐ป",bride_with_veil_tone4="๐ฐ๐พ",bride_with_veil_tone5="๐ฐ๐ฟ",bride_with_veil_tone2="๐ฐ๐ผ",bride_with_veil_tone3="๐ฐ๐ฝ",bride_with_veil_tone1="๐ฐ๐ป",hibiscus="๐บ",black_joker="๐",raised_hand="โ",no_mouth="๐ถ",basketball_player="โน",champagne="๐พ",no_entry_sign="๐ซ",older_man="๐ด",moyai="๐ฟ",mailbox="๐ซ",slight_frown="๐",statue_of_liberty="๐ฝ",mega="๐ฃ",eggplant="๐",rose="๐น",bell="๐",battery="๐",wastebasket="๐",dancer="๐",page_facing_up="๐",church="โช",underage="๐",secret="ใ",clock430="๐",fork_knife_plate="๐ฝ",u7981="๐ฒ",fire="๐ฅ",cold_sweat="๐ฐ",flag_er="๐ช๐ท",family_mwgg="๐จ๐ฉ๐ง๐ง",heart_eyes="๐",guardsman_tone1="๐๐ป",guardsman_tone2="๐๐ผ",guardsman_tone3="๐๐ฝ",guardsman_tone4="๐๐พ",earth_africa="๐",arrow_right_hook="โช",spy_tone2="๐ต๐ผ",closed_umbrella="๐",bikini="๐",vertical_traffic_light="๐ฆ",kissing="๐",loop="โฟ",potable_water="๐ฐ",pound="๐ท",["fleur-de-lis"]="โ",key2="๐",heavy_dollar_sign="๐ฒ",shamrock="โ",boy_tone4="๐ฆ๐พ",shirt="๐",kimono="๐",left_luggage="๐
",meat_on_bone="๐",ok_woman_tone4="๐๐พ",ok_woman_tone5="๐๐ฟ",arrow_heading_up="โคด",calendar_spiral="๐",snail="๐",ok_woman_tone1="๐๐ป",arrow_down_small="๐ฝ",leopard="๐",paperclips="๐",cityscape="๐",woman_tone1="๐ฉ๐ป",slot_machine="๐ฐ",woman_tone3="๐ฉ๐ฝ",woman_tone4="๐ฉ๐พ",woman_tone5="๐ฉ๐ฟ",euro="๐ถ",musical_score="๐ผ",triangular_ruler="๐",flags="๐",five="5โฃ",love_hotel="๐ฉ",hotdog="๐ญ",speak_no_evil="๐",eyeglasses="๐",dancer_tone4="๐๐พ",dancer_tone5="๐๐ฟ",vulcan_tone4="๐๐พ",bridge_at_night="๐",writing_hand_tone1="โ๐ป",couch="๐",vulcan_tone1="๐๐ป",vulcan_tone2="๐๐ผ",vulcan_tone3="๐๐ฝ",womans_hat="๐",sandal="๐ก",cherries="๐",full_moon="๐",flag_om="๐ด๐ฒ",play_pause="โฏ",couple="๐ซ",money_mouth="๐ค",womans_clothes="๐",globe_with_meridians="๐",bath_tone5="๐๐ฟ",bangbang="โผ",stuck_out_tongue_winking_eye="๐",heart="โค",bamboo="๐",mahjong="๐",waning_gibbous_moon="๐",back="๐",point_up_2_tone4="๐๐พ",point_up_2_tone5="๐๐ฟ",lips="๐",point_up_2_tone1="๐๐ป",point_up_2_tone2="๐๐ผ",point_up_2_tone3="๐๐ฝ",candle="๐ฏ",middle_finger_tone3="๐๐ฝ",middle_finger_tone2="๐๐ผ",middle_finger_tone1="๐๐ป",middle_finger_tone5="๐๐ฟ",middle_finger_tone4="๐๐พ",heavy_minus_sign="โ",nose="๐",zzz="๐ค",stew="๐ฒ",santa="๐
",tropical_fish="๐ ",point_up_tone1="โ๐ป",point_up_tone3="โ๐ฝ",point_up_tone2="โ๐ผ",point_up_tone5="โ๐ฟ",point_up_tone4="โ๐พ",field_hockey="๐",school_satchel="๐",womens="๐บ",baby_symbol="๐ผ",baby_chick="๐ค",ok_hand_tone2="๐๐ผ",ok_hand_tone3="๐๐ฝ",ok_hand_tone1="๐๐ป",ok_hand_tone4="๐๐พ",ok_hand_tone5="๐๐ฟ",family_mmgb="๐จ๐จ๐ง๐ฆ",last_quarter_moon="๐",tada="๐",clock530="๐ ",question="โ",registered="ยฎ",level_slider="๐",black_circle="โซ",atom="โ",penguin="๐ง",electric_plug="๐",skull="๐",kiss_mm="๐จโค๐๐จ",walking_tone4="๐ถ๐พ",fries="๐",up="๐",walking_tone3="๐ถ๐ฝ",walking_tone2="๐ถ๐ผ",athletic_shoe="๐",hatched_chick="๐ฅ",black_nib="โ",black_large_square="โฌ",bow_and_arrow="๐น",rainbow="๐",metal_tone5="๐ค๐ฟ",metal_tone4="๐ค๐พ",lemon="๐",metal_tone2="๐ค๐ผ",peach="๐",peace="โฎ",steam_locomotive="๐",oncoming_bus="๐",heart_eyes_cat="๐ป",smiley="๐",haircut_tone1="๐๐ป",haircut_tone2="๐๐ผ",u6e80="๐ต",haircut_tone4="๐๐พ",haircut_tone5="๐๐ฟ",black_medium_square="โผ",closed_book="๐",desert="๐",expressionless="๐",dvd="๐",construction_worker_tone2="๐ท๐ผ",construction_worker_tone3="๐ท๐ฝ",construction_worker_tone4="๐ท๐พ",construction_worker_tone5="๐ท๐ฟ",mag_right="๐",bento="๐ฑ",scroll="๐",flag_nl="๐ณ๐ฑ",flag_no="๐ณ๐ด",flag_ni="๐ณ๐ฎ",european_post_office="๐ค",flag_ne="๐ณ๐ช",flag_nf="๐ณ๐ซ",flag_ng="๐ณ๐ฌ",flag_na="๐ณ๐ฆ",flag_nc="๐ณ๐จ",alien="๐ฝ",first_quarter_moon_with_face="๐",flag_nz="๐ณ๐ฟ",flag_nu="๐ณ๐บ",golfer="๐",flag_np="๐ณ๐ต",flag_nr="๐ณ๐ท",anguished="๐ง",mosque="๐",point_left_tone1="๐๐ป",ear_tone1="๐๐ป",ear_tone2="๐๐ผ",ear_tone3="๐๐ฝ",ear_tone4="๐๐พ",ear_tone5="๐๐ฟ",eight_pointed_black_star="โด",wave="๐",runner_tone5="๐๐ฟ",runner_tone4="๐๐พ",runner_tone3="๐๐ฝ",runner_tone2="๐๐ผ",runner_tone1="๐๐ป",railway_car="๐",notes="๐ถ",no_good="๐
",trackball="๐ฒ",spaghetti="๐",love_letter="๐",clipboard="๐",baby_bottle="๐ผ",bird="๐ฆ",card_index="๐",punch="๐",leo="โ",house_with_garden="๐ก",family_wwgg="๐ฉ๐ฉ๐ง๐ง",family_wwgb="๐ฉ๐ฉ๐ง๐ฆ",see_no_evil="๐",metro="๐",popcorn="๐ฟ",apple="๐",scales="โ",sleeping_accommodation="๐",clock230="๐",tools="๐ ",cloud="โ",honey_pot="๐ฏ",ballot_box="๐ณ",frog="๐ธ",camera="๐ท",crab="๐ฆ",video_camera="๐น",pencil="๐",thunder_cloud_rain="โ",mountain_bicyclist="๐ต",tangerine="๐",train="๐",rabbit="๐ฐ",baby="๐ถ",palm_tree="๐ด",capital_abcd="๐ ",put_litter_in_its_place="๐ฎ",coffin="โฐ",abcd="๐ก",lock="๐",pig2="๐",family_mwg="๐จ๐ฉ๐ง",point_right_tone5="๐๐ฟ",trumpet="๐บ",film_frames="๐",six="6โฃ",leftwards_arrow_with_hook="โฉ",earth_asia="๐",heavy_check_mark="โ",notebook="๐",taco="๐ฎ",tomato="๐
",robot="๐ค",mute="๐",symbols="๐ฃ",motorcycle="๐",thermometer_face="๐ค",paperclip="๐",moneybag="๐ฐ",neutral_face="๐",white_sun_rain_cloud="๐ฆ",snake="๐",kiss="๐",blue_car="๐",confetti_ball="๐",tram="๐",repeat_one="๐",smiley_cat="๐บ",beginner="๐ฐ",mobile_phone_off="๐ด",books="๐",["8ball"]="๐ฑ",cocktail="๐ธ",flag_ge="๐ฌ๐ช",horse_racing_tone2="๐๐ผ",flag_mh="๐ฒ๐ญ",flag_mk="๐ฒ๐ฐ",flag_mm="๐ฒ๐ฒ",flag_ml="๐ฒ๐ฑ",flag_mo="๐ฒ๐ด",flag_mn="๐ฒ๐ณ",flag_ma="๐ฒ๐ฆ",flag_mc="๐ฒ๐จ",flag_me="๐ฒ๐ช",flag_md="๐ฒ๐ฉ",flag_mg="๐ฒ๐ฌ",flag_mf="๐ฒ๐ซ",flag_my="๐ฒ๐พ",flag_mx="๐ฒ๐ฝ",flag_mz="๐ฒ๐ฟ",mountain_snow="๐",flag_mp="๐ฒ๐ต",flag_ms="๐ฒ๐ธ",flag_mr="๐ฒ๐ท",flag_mu="๐ฒ๐บ",flag_mt="๐ฒ๐น",flag_mw="๐ฒ๐ผ",flag_mv="๐ฒ๐ป",timer="โฒ",passport_control="๐",small_blue_diamond="๐น",lion_face="๐ฆ",white_check_mark="โ
",bouquet="๐",track_previous="โฎ",monkey="๐",tone4="๐พ",closed_lock_with_key="๐",family_wwb="๐ฉ๐ฉ๐ฆ",family_wwg="๐ฉ๐ฉ๐ง",tone1="๐ป",crescent_moon="๐",shell="๐",gear="โ",tone2="๐ผ",small_red_triangle_down="๐ป",nut_and_bolt="๐ฉ",umbrella2="โ",unamused="๐",fuelpump="โฝ",bed="๐",bee="๐",round_pushpin="๐",flag_white="๐ณ",microphone="๐ค",bus="๐",eight="8โฃ",handbag="๐",medal="๐
",arrows_clockwise="๐",urn="โฑ",bookmark_tabs="๐",new_moon_with_face="๐",fallen_leaf="๐",horse_racing="๐",chicken="๐",ear="๐",wheel_of_dharma="โธ",arrow_lower_right="โ",man_with_gua_pi_mao_tone5="๐ฒ๐ฟ",scorpion="๐ฆ",waning_crescent_moon="๐",man_with_gua_pi_mao_tone2="๐ฒ๐ผ",man_with_gua_pi_mao_tone1="๐ฒ๐ป",bug="๐",virgo="โ",libra="โ",angel_tone1="๐ผ๐ป",angel_tone3="๐ผ๐ฝ",angel_tone2="๐ผ๐ผ",angel_tone5="๐ผ๐ฟ",sagittarius="โ",bear="๐ป",information_desk_person_tone3="๐๐ฝ",no_mobile_phones="๐ต",hand_splayed="๐",motorboat="๐ฅ",calling="๐ฒ",interrobang="โ",oncoming_taxi="๐",flag_lt="๐ฑ๐น",flag_lu="๐ฑ๐บ",flag_lr="๐ฑ๐ท",flag_ls="๐ฑ๐ธ",flag_ly="๐ฑ๐พ",bellhop="๐",arrow_down="โฌ",flag_lc="๐ฑ๐จ",flag_la="๐ฑ๐ฆ",flag_lk="๐ฑ๐ฐ",flag_li="๐ฑ๐ฎ",ferris_wheel="๐ก",hand_splayed_tone2="๐๐ผ",large_blue_diamond="๐ท",cat2="๐",icecream="๐ฆ",tent="โบ",joy="๐",hand_splayed_tone4="๐๐พ",file_cabinet="๐",key="๐",weary="๐ฉ",bath_tone2="๐๐ผ",flag_lv="๐ฑ๐ป",low_brightness="๐
",rowboat_tone5="๐ฃ๐ฟ",rowboat_tone2="๐ฃ๐ผ",rowboat_tone3="๐ฃ๐ฝ",four_leaf_clover="๐",space_invader="๐พ",cl="๐",cd="๐ฟ",bath_tone4="๐๐พ",flag_za="๐ฟ๐ฆ",swimmer="๐",wavy_dash="ใฐ",flag_zm="๐ฟ๐ฒ",flag_zw="๐ฟ๐ผ",raised_hands_tone5="๐๐ฟ",two_hearts="๐",bulb="๐ก",cop_tone4="๐ฎ๐พ",cop_tone5="๐ฎ๐ฟ",cop_tone2="๐ฎ๐ผ",cop_tone3="๐ฎ๐ฝ",cop_tone1="๐ฎ๐ป",open_file_folder="๐",homes="๐",raised_hands_tone2="๐๐ผ",fearful="๐จ",grinning="๐",bow_tone5="๐๐ฟ",santa_tone3="๐
๐ฝ",santa_tone2="๐
๐ผ",santa_tone5="๐
๐ฟ",santa_tone4="๐
๐พ",bow_tone2="๐๐ผ",bow_tone3="๐๐ฝ",bathtub="๐",ping_pong="๐",u5272="๐น",rooster="๐",vs="๐",bullettrain_front="๐
",airplane_small="๐ฉ",white_circle="โช",balloon="๐",cross="โ",princess_tone3="๐ธ๐ฝ",speaker="๐",zipper_mouth="๐ค",u6307="๐ฏ",whale="๐ณ",pensive="๐",signal_strength="๐ถ",muscle="๐ช",rocket="๐",camel="๐ซ",boot="๐ข",flashlight="๐ฆ",spy_tone4="๐ต๐พ",spy_tone5="๐ต๐ฟ",ski="๐ฟ",spy_tone3="๐ต๐ฝ",musical_keyboard="๐น",spy_tone1="๐ต๐ป",rolling_eyes="๐",clock1="๐",clock2="๐",clock3="๐",clock4="๐",clock5="๐",clock6="๐",clock7="๐",clock8="๐",clock9="๐",doughnut="๐ฉ",dancer_tone1="๐๐ป",dancer_tone2="๐๐ผ",dancer_tone3="๐๐ฝ",candy="๐ฌ",two_men_holding_hands="๐ฌ",badminton="๐ธ",bust_in_silhouette="๐ค",writing_hand_tone2="โ๐ผ",sunflower="๐ป",["e-mail"]="๐ง",chains="โ",kissing_smiling_eyes="๐",fish_cake="๐ฅ",no_pedestrians="๐ท",v_tone4="โ๐พ",v_tone5="โ๐ฟ",v_tone1="โ๐ป",v_tone2="โ๐ผ",v_tone3="โ๐ฝ",arrow_backward="โ",clock12="๐",clock10="๐",clock11="๐",sweat="๐",mountain_railway="๐",tongue="๐
",black_square_button="๐ฒ",do_not_litter="๐ฏ",nose_tone4="๐๐พ",nose_tone5="๐๐ฟ",nose_tone2="๐๐ผ",nose_tone3="๐๐ฝ",nose_tone1="๐๐ป",horse_racing_tone5="๐๐ฟ",horse_racing_tone4="๐๐พ",horse_racing_tone3="๐๐ฝ",ok_hand="๐",horse_racing_tone1="๐๐ป",custard="๐ฎ",rowboat="๐ฃ",white_sun_small_cloud="๐ค",flag_kr="๐ฐ๐ท",cricket="๐",flag_kp="๐ฐ๐ต",flag_kw="๐ฐ๐ผ",flag_kz="๐ฐ๐ฟ",flag_ky="๐ฐ๐พ",construction="๐ง",flag_kg="๐ฐ๐ฌ",flag_ke="๐ฐ๐ช",flag_ki="๐ฐ๐ฎ",flag_kh="๐ฐ๐ญ",flag_kn="๐ฐ๐ณ",flag_km="๐ฐ๐ฒ",cake="๐ฐ",flag_wf="๐ผ๐ซ",mortar_board="๐",pig="๐ท",flag_ws="๐ผ๐ธ",person_frowning="๐",arrow_upper_right="โ",book="๐",clock1130="๐ฆ",boom="๐ฅ",["repeat"]="๐",star="โญ",rabbit2="๐",footprints="๐ฃ",ghost="๐ป",droplet="๐ง",vibration_mode="๐ณ",flag_ye="๐พ๐ช",flag_yt="๐พ๐น", +} +emoji['slightly_smiling_face'] = ':)' + +local function str2emoji(str) + if not str then return '' end + return (str:gsub(':[a-zA-Z0-9%-_]+:', function(word) + return emoji[word:match(':(.+):')] or word + end)) +end + +function emoji_replace_input_string(buffer) + -- Get input contents + local input_s = w.buffer_get_string(buffer, 'input') + -- Skip modification of settings + if input_s:match('^/set ') then + return w.WEECHAT_RC_OK + end + w.buffer_set(buffer, 'input', str2emoji(input_s)) + return w.WEECHAT_RC_OK +end + +function emoji_input_replacer(data, buffer, command) + if command == '/input return' then + return emoji_replace_input_string(buffer) + end + return w.WEECHAT_RC_OK +end + +function emoji_live_input_replace(data, modifier, modifier_data, msg) + return str2emoji(msg) +end + +function emoji_out_replace(data, modifier, modifier_data, msg) + return str2emoji(msg) +end + +function unshortcode_cb(data, modifier, modifier_data, msg) + return str2emoji(msg) +end + +function emoji_complete_next_cb(data, buffer, command) + local input_s = w.buffer_get_string(buffer, 'input') + -- Require : in word + if not input_s:match(':') then + return w.WEECHAT_RC_OK + end + local current_pos = w.buffer_get_integer(buffer, "input_pos") - 1 + --local input_length = w.buffer_get_integer(buffer, "input_length") + while current_pos >= 1 and input_s.sub(current_pos, current_pos) ~= ':' do + current_pos = current_pos - 1 + end + if current_pos < 1 then + current_pos = 1 + end + + -- TODO: Support non-end-word editing + local oword = input_s:sub(current_pos) + local word = oword:match(':(.*)') + for e, b in pairs(emoji) do + if e:match(word) then + local new = (input_s:gsub(":"..word, b)) + w.buffer_set(buffer, 'input', new) + --w.buffer_set(buffer, "input_pos", str(w.buffer_get_integer(buffer, "input_pos") + 1)) + return w.WEECHAT_RC_OK_EAT + end + end + + return w.WEECHAT_RC_OK +end + +function emoji_completion_cb(data, completion_item, buffer, completion) + for k, v in pairs(emoji) do + w.hook_completion_list_add(completion, ":"..k..":", 0, w.WEECHAT_LIST_POS_SORT) + end + return w.WEECHAT_RC_OK +end + +function incoming_cb(data, modifier, modifier_data, msg) + --local plugin, buffer_name, tags = modifier_data:match('^(.-);(.-);(.-)$') + -- Only replace in incoming "messages" + if modifier_data:match('nick_') then + return str2emoji(msg) + end + return msg +end + +function e_init() + if w.register( + SCRIPT_NAME, + SCRIPT_AUTHOR, + SCRIPT_VERSION, + SCRIPT_LICENSE, + SCRIPT_DESC, + '', + '') then + -- Hook input enter + w.hook_command_run('/input return', 'emoji_input_replacer', '') + + -- Hook irc out for relay clients + --w.hook_modifier('input_text_for_buffer', 'emoji_out_replace', '') + w.hook_modifier('irc_out1_PRIVMSG', 'emoji_out_replace', '') + -- Replace while typing + w.hook_modifier('input_text_display_with_cursor', 'emoji_live_input_replace', '') + -- Hook tab complete + w.hook_command_run("/input complete_next", "emoji_complete_next_cb", "") + -- Hook for working togheter with other scripts + w.hook_modifier('emoji_unshortcode', 'unshortcode_cb', '') + + w.hook_completion("emojis", "complete :emoji:s", "emoji_completion_cb", "") + local settings = { + incoming = {'on', 'Also try to replace shortcodes to emoji in incoming messages'} + } + -- set default settings + local version = tonumber(w.info_get('version_number', '') or 0) + for option, value in pairs(settings) do + if w.config_is_set_plugin(option) ~= 1 then + w.config_set_plugin(option, value[1]) + end + if version >= 0x00030500 then + w.config_set_desc_plugin(option, ('%s (default: "%s")'):format( + value[2], value[1])) + end + end + -- Hook incoming message + if w.config_get_plugin('incoming') == 'on' then + w.hook_modifier("weechat_print", 'incoming_cb', '') + end + end +end + +e_init() diff --git a/weechat/lua/matrix.lua b/weechat/lua/matrix.lua new file mode 100644 index 0000000..2ce0a2c --- /dev/null +++ b/weechat/lua/matrix.lua @@ -0,0 +1,3354 @@ +-- WeeChat Matrix.org Client +-- vim: expandtab:ts=4:sw=4:sts=4 +-- luacheck: globals weechat command_help command_connect matrix_command_cb matrix_away_command_run_cb configuration_changed_cb real_http_cb matrix_unload http_cb upload_cb send buffer_input_cb poll polltimer_cb cleartyping otktimer_cb join_command_cb part_command_cb leave_command_cb me_command_cb topic_command_cb upload_command_cb query_command_cb create_command_cb createalias_command_cb invite_command_cb list_command_cb op_command_cb voice_command_cb devoice_command_cb kick_command_cb deop_command_cb nick_command_cb whois_command_cb notice_command_cb msg_command_cb encrypt_command_cb public_command_cb names_command_cb more_command_cb roominfo_command_cb name_command_cb closed_matrix_buffer_cb closed_matrix_room_cb typing_notification_cb buffer_switch_cb typing_bar_item_cb + +--[[ + Author: xt <xt@xt.gg> + Thanks to Ryan Huber of wee_slack.py for some ideas and inspiration. + + This script is considered alpha quality as only the bare minimal of + functionality is in place and it is not very well tested. + + It is known to be able to crash WeeChat in certain scenarioes so all + usage of this script is at your own risk. + + If at any point there seems to be problem, make sure you update to + the latest version of this script. You can also try reloading the + script using /lua reload matrix to refresh all the state. + +Power Levels +------------ + +A default Matrix room has power level between 0 to 100. +This script maps this as follows: + + ~ Room creator + & Power level 100 + @ Power level 50 + + Power level > 0 + + TODO + ---- + /ban + Giving people arbitrary power levels + Lazyload messages instead of HUGE initialSync + Dynamically fetch more messages in backlog when user reaches the + oldest message using pgup + Need a way to change room join rule + Fix broken state after failed initial connect + Fix parsing of multiple join messages + Friendlier error message on bad user/password + Parse some HTML and turn into color/bold/etc + Support weechat.look.prefix_same_nick + +]] + +local json = require 'cjson' -- apt-get install lua-cjson +local olmstatus, olm = pcall(require, 'olm') -- LuaJIT olm FFI binding ln -s ~/olm/olm.lua /usr/local/share/lua/5.1 +local w = weechat + +local SCRIPT_NAME = "matrix" +local SCRIPT_AUTHOR = "xt <xt@xt.gg>" +local SCRIPT_VERSION = "3" +local SCRIPT_LICENSE = "MIT" +local SCRIPT_DESC = "Matrix.org chat plugin" +local SCRIPT_COMMAND = SCRIPT_NAME + +local WEECHAT_VERSION + +local SERVER +local STDOUT = {} +local OUT = {} +local BUFFER +local Room +local MatrixServer +local Olm +local DEBUG = false +-- How many seconds to timeout if nothing happened on the server. If something +-- happens before it will return sooner. +-- default Nginx proxy timeout is 60s, so we go slightly lower +local POLL_INTERVAL = 55 + +local default_color = w.color('default') +-- Cache error variables so we don't have to look them up for every error +-- message, a normal user will not change these ever anyway. +local errprefix +local errprefix_c + +local HOMEDIR +local OLM_ALGORITHM = 'm.olm.v1.curve25519-aes-sha2' +local OLM_KEY = 'secr3t' -- TODO configurable using weechat sec data +local v2_api_ns = '_matrix/client/v2_alpha' + +local function tprint(tbl, indent, out) + if not indent then indent = 0 end + if not out then out = BUFFER end + for k, v in pairs(tbl) do + local formatting = string.rep(" ", indent) .. k .. ": " + if type(v) == "table" then + w.print(out, formatting) + tprint(v, indent+1, out) + elseif type(v) == 'boolean' then + w.print(out, formatting .. tostring(v)) + elseif type(v) == 'userdata' then + w.print(out, formatting .. tostring(v)) + else + w.print(out, formatting .. v) + end + end +end + +local function mprint(message) + -- Print message to matrix buffer + if type(message) == 'table' then + tprint(message) + else + message = tostring(message) + w.print(BUFFER, message) + end +end + +local function perr(message) + if message == nil then return end + -- Print error message to the matrix "server" buffer using WeeChat styled + -- error message + mprint( + errprefix_c .. + errprefix .. + '\t' .. + default_color .. + tostring(message) + ) +end + +local function dbg(message) + perr('- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -') + if type(message) == 'table' then + tprint(message) + else + message = ("DEBUG\t%s"):format(tostring(message)) + mprint(BUFFER, message) + end +end + +local dtraceback = debug.traceback +-- luacheck: ignore debug +debug.traceback = function (...) + if select('#', ...) >= 1 then + local err, lvl = ... + local trace = dtraceback(err, (lvl or 2)+1) + perr(trace) + end + -- direct call to debug.traceback: return the original. + -- debug.traceback(nil, level) doesn't work in Lua 5.1 + -- (http://lua-users.org/lists/lua-l/2011-06/msg00574.html), so + -- simply remove first frame from the stack trace + return (dtraceback(...):gsub("(stack traceback:\n)[^\n]*\n", "%1")) +end + +local function weechat_eval(text) + if WEECHAT_VERSION >= 0x00040200 then + return w.string_eval_expression(text,{},{},{}) + end + return text +end + +local urllib = {} +urllib.quote = function(str) + if not str then return '' end + if type(str) == 'number' then return str end + return str:gsub( + '([^%w ])', + function (c) + return string.format ("%%%02X", string.byte(c)) + end + ):gsub(' ', '+') +end +urllib.urlencode = function(tbl) + local out = {} + for k, v in pairs(tbl) do + table.insert(out, urllib.quote(k)..'='..urllib.quote(v)) + end + return table.concat(out, '&') +end + +local function accesstoken_redact(str) + return (str:gsub('access.*token=[0-9a-zA-Z%%]*', 'access_token=[redacted]')) +end + +local transaction_id_counter = 0 +local function get_next_transaction_id() + transaction_id_counter = transaction_id_counter + 1 + return transaction_id_counter +end + +--[[ +-- Function for signing json, unused for now, we hand craft the required +-- signed json in the encryption function. But I think this function will be +-- needed in the future so leaving it here in a commented version +local function sign_json(json_object, signing_key, signing_name) + -- See: https://github.com/matrix-org/matrix-doc/blob/master/specification/31_event_signing.rst + -- Maybe use:http://regex.info/code/JSON.lua which sorts keys + local signatures = json_object.signatures or {} + json_object.signatures = nil + + local unsigned = json_object.unsigned or nil + json_object.unsigned = nil + + -- TODO ensure canonical json + local signed = signing_key:sign(json.encode(json_object)) + local signature_base64 = encode_base64(signed.signature) + + local key_id = ("%s:%s"):format(signing_key.agl, signing_key.version) + signatures[signing_name] = {[key_id] = signature_base64} + + json_object.signatures = signatures + if unsigned then + json_object.unsigned = unsigned + end + + return json_object +end +--]] + +local function split_args(args) + local function_name, arg = args:match('^(.-) (.*)$') + return function_name, arg +end + +local function byte_to_tag(s, byte, open_tag, close_tag) + if s:match(byte) then + local inside = false + local open_tags = 0 + local htmlbody = s:gsub(byte, function(c) + if inside then + inside = false + return close_tag + end + inside = true + open_tags = open_tags + 1 + return open_tag + end) + local _, count = htmlbody:gsub(close_tag, '') + -- Ensure we close tags + if count < open_tags then + htmlbody = htmlbody .. close_tag + end + return htmlbody + end + return s +end + +local function irc_formatting_to_html(s) + -- TODO, support foreground and background? + local ct = {'white','black','blue','green','red','maroon','purple', + 'orange','yellow','lightgreen','teal','cyan', 'lightblue', + 'fuchsia', 'gray', 'lightgray'} + + s = byte_to_tag(s, '\02', '<em>', '</em>') + s = byte_to_tag(s, '\029', '<i>', '</i>') + s = byte_to_tag(s, '\031', '<u>', '</u>') + -- First do full color strings with reset. + -- Iterate backwards to catch long colors before short + for i=#ct,1,-1 do + s = s:gsub( + '\003'..tostring(i-1)..'(.-)\003', + '<font color="'..ct[i]..'">%1</font>') + end + + -- Then replace unmatch colors + -- Iterate backwards to catch long colors before short + for i=#ct,1,-1 do + local c = ct[i] + s = byte_to_tag(s, '\003'..tostring(i-1), + '<font color="'..c..'">', '</font>') + end + return s +end + +local function strip_irc_formatting(s) + if not s then return '' end + return (s + :gsub("\02", "") + :gsub("\03%d%d?,%d%d?", "") + :gsub("\03%d%d?", "") + :gsub("\03", "") + :gsub("\15", "") + :gsub("\17", "") + :gsub("\18", "") + :gsub("\22", "") + :gsub("\29", "") + :gsub("\31", "")) +end + +local function irc_formatting_to_weechat_color(s) + -- TODO, support foreground and background? + -- - is atribute to remove formatting + -- | is to keep formatting during color changes + s = byte_to_tag(s, '\02', w.color'bold', w.color'-bold') + s = byte_to_tag(s, '\029', w.color'italic', w.color'-italic') + s = byte_to_tag(s, '\031', w.color'underline', w.color'-underline') + -- backwards to catch long numbers before short + for i=16,1,-1 do + i = tostring(i) + s = byte_to_tag(s, '\003'..i, + w.color("|"..i), w.color("-"..i)) + end + return s +end + +function matrix_unload() + w.print('', 'matrix: Unloading') + -- Clear/free olm memory if loaded + if olmstatus then + w.print('', 'matrix: Saving olm state') + SERVER.olm:save() + w.print('', 'matrix: Clearing olm state from memory') + SERVER.olm.account:clear() + --SERVER.olm = nil + end + w.print('', 'matrix: done cleaning up!') + return w.WEECHAT_RC_OK +end + +local function wconf(optionname) + return w.config_string(w.config_get(optionname)) +end + +local function wcolor(optionname) + return w.color(wconf(optionname)) +end + +function command_help(current_buffer, args) + if args then + local help_cmds = {args=args} + if not help_cmds then + w.print("", "Command not found: " .. args) + return + end + for cmd, helptext in pairs(help_cmds) do + w.print('', w.color("bold") .. cmd) + w.print('', (helptext or 'No help text').strip()) + w.print('', '') + end + end + +end + +function command_connect(current_buffer, args) + if not SERVER.connected then + SERVER:connect() + end + return w.WEECHAT_RC_OK +end + +function matrix_command_cb(data, current_buffer, args) + if args == 'connect' then + return command_connect(current_buffer) + elseif args == 'debug' then + if DEBUG then + DEBUG = false + w.print('', SCRIPT_NAME..': debugging messages disabled') + else + DEBUG = true + w.print('', SCRIPT_NAME..': debugging messages enabled') + end + elseif args:match('^msg ') then + local _ + _, args = split_args(args) -- remove cmd + local roomarg, msg = split_args(args) + local room + for id, r in pairs(SERVER.rooms) do + -- Send /msg to a ID + if id == roomarg then + room = r + break + elseif roomarg == r.name then + room = r + break + elseif roomarg == r.roomname then + room = r + break + end + end + + if room then + room:Msg(msg) + return w.WEECHAT_RC_OK_EAT + end + else + perr("Command not found: " .. args) + end + + return w.WEECHAT_RC_OK +end + +function matrix_away_command_run_cb(data, buffer, args) + -- Callback for command /away -all + local _ + _, args = split_args(args) -- remove cmd + local msg + _, msg = split_args(args) + w.buffer_set(BUFFER, "localvar_set_away", msg) + for id, room in pairs(SERVER.rooms) do + if msg and msg ~= '' then + w.buffer_set(room.buffer, "localvar_set_away", msg) + else + -- Delete takes empty string, and not nil + w.buffer_set(room.buffer, "localvar_del_away", '') + end + end + if msg and msg ~= '' then + SERVER:SendPresence('unavailable', msg) + mprint 'You have been marked as unavailable' + else + SERVER:SendPresence('online', nil) + mprint 'You have been marked as online' + end + return w.WEECHAT_RC_OK +end + +function configuration_changed_cb(data, option, value) + if value == 'on' then + DEBUG = true + w.print('', SCRIPT_NAME..': debugging messages enabled') + else + DEBUG = false + w.print('', SCRIPT_NAME..': debugging messages disabled') + end +end + +local function http(url, post, cb, timeout, extra, api_ns) + if not post then + post = {} + end + if not cb then + cb = 'http_cb' + end + if not timeout then + timeout = 60*1000 + end + if not extra then + extra = nil + end + if not api_ns then + api_ns = "_matrix/client/r0" + end + + -- Add accept encoding by default if it's not already there + if not post.accept_encoding then + post.accept_encoding = 'application/json' + end + if not post.header then + post.header = 1 -- Request http headers in the response + end + + if not url:match'https?://' then + local homeserver_url = w.config_get_plugin('homeserver_url') + homeserver_url = homeserver_url .. api_ns + url = homeserver_url .. url + end + + if DEBUG then + dbg{request={ + url=accesstoken_redact(url), + post=post,extra=extra} + } + end + w.hook_process_hashtable('url:' .. url, post, timeout, cb, extra) +end + +local function parse_http_statusline(line) + local httpversion, status_code, reason_phrase = line:match("^HTTP/(1%.[01]) (%d%d%d) (.-)\r?\n") + if not httpversion then + return + end + return httpversion, tonumber(status_code), reason_phrase +end + +function real_http_cb(extra, command, rc, stdout, stderr) + if DEBUG then + dbg{reply={ + command=accesstoken_redact(command), + extra=extra,rc=rc,stdout=stdout,stderr=stderr} + } + end + + if stderr and stderr ~= '' then + mprint(('error: %s'):format(stderr)) + return w.WEECHAT_RC_OK + end + + -- Because of a bug in WeeChat sometimes the stdout gets prepended by + -- any number of BEL chars (hex 07). Let's have a nasty workaround and + -- just replace them away. + if WEECHAT_VERSION < 0x01030000 then -- fixed in 1.3 + stdout = (stdout:gsub('^\007*', '')) + end + + if stdout ~= '' then + if not STDOUT[command] then + STDOUT[command] = {} + end + table.insert(STDOUT[command], stdout) + end + + if tonumber(rc) >= 0 then + stdout = table.concat(STDOUT[command] or {}) + STDOUT[command] = nil + local httpversion, status_code, reason_phrase = parse_http_statusline(stdout) + if not httpversion then + perr(('Invalid http request: %s'):format(stdout)) + return w.WEECHAT_RC_OK + end + if status_code >= 500 then + perr(('HTTP API returned error. Code: %s, reason: %s'):format(status_code, reason_phrase)) + return w.WEECHAT_RC_OK + end + -- Skip to data + stdout = (stdout:match('.-\r?\n\r?\n(.*)')) + -- Protected call in case of JSON errors + local success, js = pcall(json.decode, stdout) + if not success then + mprint(('error\t%s during json load: %s'):format(js, stdout)) + return w.WEECHAT_RC_OK + end + if js['errcode'] or js['error'] then + if command:find'login' then + w.print('', ('matrix: Error code during login: %s, code: %s'):format( + js['error'], js['errcode'])) + w.print('', 'matrix: Please verify your username and password') + else + perr('API call returned error: '..js['error'] .. '('..tostring(js.errcode)..')') + end + return w.WEECHAT_RC_OK + end + -- Get correct handler + if command:find('login') then + for k, v in pairs(js) do + SERVER[k] = v + end + SERVER.connected = true + SERVER:initial_sync() + elseif command:find'/rooms/.*/initialSync' then + local myroom = SERVER:addRoom(js) + for _, chunk in ipairs(js['presence']) do + myroom:ParseChunk(chunk, true, 'presence') + end + for _, chunk in ipairs(js['messages']['chunk']) do + myroom:ParseChunk(chunk, true, 'messages') + end + elseif command:find'/sync' then + SERVER.end_token = js.next_batch + + -- We have a new end token, which means we safely can release the + -- poll lock + SERVER.poll_lock = false + + local backlog = false + local initial = false + if extra == 'initial' then + initial = true + backlog = true + end + + -- Start with setting the global presence variable on the server + -- so when the nicks get added to the room they can get added to + -- the correct nicklist group according to if they have presence + -- or not + for _, e in ipairs(js.presence.events) do + SERVER:UpdatePresence(e) + end + for membership, rooms in pairs(js['rooms']) do + -- If we left the room, simply ignore it + if membership ~= 'leave' or (membership == 'leave' and (not backlog)) then + for identifier, room in pairs(rooms) do + -- Monkey patch it to look like v1 object + room.room_id = identifier + local myroom + if initial then + myroom = SERVER:addRoom(room) + else + myroom = SERVER.rooms[identifier] + -- Chunk for non-existing room + if not myroom then + myroom = SERVER:addRoom(room) + if not membership == 'invite' then + perr('Event for unknown room') + end + end + end + -- First of all parse invite states. + local inv_states = room.invite_state + if inv_states then + local chunks = room.invite_state.events or {} + for _, chunk in ipairs(chunks) do + myroom:ParseChunk(chunk, backlog, 'states') + end + end + -- Parse states before messages so we can add nicks and stuff + -- before messages start appearing + local states = room.state + if states then + local chunks = room.state.events or {} + for _, chunk in ipairs(chunks) do + myroom:ParseChunk(chunk, backlog, 'states') + end + end + local timeline = room.timeline + if timeline then + -- Save the prev_batch on the initial message so we + -- know for later when we picked up the sync + if initial then + myroom.prev_batch = timeline.prev_batch + end + local chunks = timeline.events or {} + for _, chunk in ipairs(chunks) do + myroom:ParseChunk(chunk, backlog, 'messages') + end + end + local ephemeral = room.ephemeral + -- Ignore Ephemeral Events during initial sync + if (extra and extra ~= 'initial') and ephemeral then + local chunks = ephemeral.events or {} + for _, chunk in ipairs(chunks) do + myroom:ParseChunk(chunk, backlog, 'states') + end + end + if backlog then + -- All the state should be done. Try to get a good name for the room now. + myroom:SetName(myroom.identifier) + end + end + end + end + -- Now we have created rooms and can go over the rooms and update + -- the presence for each nick + for _, e in pairs(js.presence.events) do + SERVER:UpdatePresence(e) + end + if initial then + SERVER:post_initial_sync() + end + SERVER:poll() + elseif command:find'messages' then + local identifier = extra + local myroom = SERVER.rooms[identifier] + myroom.prev_batch = js['end'] + -- Freeze buffer + myroom:Freeze() + -- Clear buffer + myroom:Clear() + -- We request backwards direction, so iterate backwards + for i=#js.chunk,1,-1 do + local chunk = js.chunk[i] + myroom:ParseChunk(chunk, true, 'messages') + end + -- Thaw! + myroom:Thaw() + elseif command:find'/join/' then + -- We came from a join command, fecth some messages + local found = false + for id, _ in pairs(SERVER.rooms) do + if id == js.room_id then + found = true + -- this is a false positive for example when getting + -- invited. need to investigate more + --mprint('error\tJoined room, but already in it.') + break + end + end + if not found then + local data = urllib.urlencode({ + access_token= SERVER.access_token, + --limit= w.config_get_plugin('backlog_lines'), + limit = 10, + }) + http(('/rooms/%s/initialSync?%s'):format( + urllib.quote(js.room_id), data)) + end + elseif command:find'leave' then + -- We store room_id in extra + local room_id = extra + SERVER:delRoom(room_id) + elseif command:find'/keys/claim' then + local count = 0 + for user_id, v in pairs(js.one_time_keys or {}) do + for device_id, keys in pairs(v or {}) do + for key_id, key in pairs(keys or {}) do + SERVER.olm.otks[user_id..':'..device_id] = {[device_id]=key} + perr(('olm: Recieved OTK for user %s for device id %s'):format(user_id, device_id)) + count = count + 1 + SERVER.olm:create_session(user_id, device_id) + end + end + end + elseif command:find'/keys/query' then + for k, v in pairs(js.device_keys or {}) do + SERVER.olm.device_keys[k] = v + + -- Claim keys for all only if missing session + for device_id, device_data in pairs(v) do + -- First try to create session from saved data + -- if that doesn't success we will download otk + local device_key = device_data.keys['curve25519:'..device_id] + local sessions = SERVER.olm:get_sessions(device_key) + if #sessions == 0 then + perr('olm: Downloading otk for user '..k..', and device_id: '..device_id) + SERVER.olm:claim(k, device_id) + else + perr('olm: Reusing existing session for user '..k) + end + end + end + elseif command:find'/keys/upload' then + local key_count = 0 + local sensible_number_of_keys = 20 + for algo, count in pairs(js.one_time_key_counts) do + key_count = count + SERVER.olm.key_count = key_count + end + if DEBUG then + perr('olm: Number of own OTKs uploaded to server: '..key_count) + end + -- TODO make endless loop prevention in case of server error + if key_count < sensible_number_of_keys then + SERVER.olm:upload_keys() + end + elseif command:find'upload' then + -- We store room_id in extra + local room_id = extra + if js.content_uri then + SERVER:Msg(room_id, js.content_uri) + end + -- luacheck: ignore 542 + elseif command:find'/typing/' then + -- either it errs or it is empty + elseif command:find'/state/' then + -- TODO errorcode: M_FORBIDDEN + -- either it errs or it is empty + --dbg({state= js}) + elseif command:find'/send/' then + -- XXX Errorhandling + -- TODO save event id to use for localecho + local event_id = js.event_id + local room_id = extra + -- When using relay client, WeeChat doesn't get any buffer_switch + -- signals, and thus cannot know when the relay client reads any + -- messages. https://github.com/weechat/weechat/issues/180 + -- As a better than nothing approach we send read receipt when + -- user sends a message, since most likely the user has read + -- messages in that room if sending messages to it. + SERVER:SendReadReceipt(room_id, event_id) + elseif command:find'createRoom' then + -- We get join events, so we don't have to do anything + elseif command:find'/publicRooms' then + mprint 'Public rooms:' + mprint '\tUsers\tName\tTopic\tAliases' + table.sort(js.chunk, function(a, b) + return a.num_joined_members > b.num_joined_members + end) + for _, r in ipairs(js.chunk) do + local name = '' + if r.name and r.name ~= json.null then + name = r.name:gsub('\n', '') + end + local topic = '' + if r.topic and r.topic ~= json.null then + topic = r.topic:gsub('\n', '') + end + mprint(('%s %s %s %s') + :format( + r.num_joined_members or '', + name or '', + topic or '', + table.concat(r.aliases or {}, ', '))) + end + -- luacheck: ignore 542 + elseif command:find'/invite' then + elseif command:find'receipt' then + -- we don't care about receipts for now + elseif command:find'directory/room' then + --- XXX: parse result + mprint 'Created new alias for room' + elseif command:match'presence/.*/status' then + -- Return of SendPresence which we don't have to handle because + -- it will be sent back to us as an event + else + dbg{['error'] = {msg='Unknown command in http cb', command=command, + js=js}} + end + end + + if tonumber(rc) == -2 then -- -2 == WEECHAT_HOOK_PROCESS_ERROR + perr(('Call to API errored in command %s, maybe timeout?'):format( + accesstoken_redact(command))) + -- Empty cache in case of errors + if STDOUT[command] then + STDOUT[command] = nil + end + -- Release poll lock in case of errors + SERVER.poll_lock = false + end + + return w.WEECHAT_RC_OK +end + +function http_cb(data, command, rc, stdout, stderr) + local status, result = pcall(real_http_cb, data, command, rc, stdout, stderr) + if not status then + perr('Error in http_cb: ' .. tostring(result)) + perr(debug.traceback()) + end + return result +end + +function upload_cb(data, command, rc, stdout, stderr) + local success, js = pcall(json.decode, stdout) + if not success then + mprint(('error\t%s when getting uploaded URI: %s'):format(js, stdout)) + return w.WEECHAT_RC_OK + end + + local uri = js.content_uri + if not uri then + mprint(('error\tNo content_uri after upload. Stdout: %s, stderr: %s'):format(stdout, stderr)) + return w.WEECHAT_RC_OK + end + + local room_id = data + local body = 'Image' + local msgtype = 'm.image' + SERVER:Msg(room_id, body, msgtype, uri) + + return w.WEECHAT_RC_OK +end + +Olm = {} +Olm.__index = Olm +Olm.create = function() + local olmdata = {} + setmetatable(olmdata, Olm) + if not olmstatus then + w.print('', SCRIPT_NAME .. ': Unable to load olm encryption library. Not enabling encryption. Please see documentation (README.md) for information on how to enable.') + return + end + + local account = olm.Account.new() + olmdata.account = account + olmdata.sessions = {} + olmdata.device_keys = {} + olmdata.otks = {} + -- Try to read account from filesystem, if not generate a new account + local fd = io.open(HOMEDIR..'account.olm', 'rb') + local pickled = '' + if fd then + pickled = fd:read'*all' + fd:close() + end + if pickled == '' then + account:create() + local _, err = account:generate_one_time_keys(5) + perr(err) + else + local _, err = account:unpickle(OLM_KEY, pickled) + perr(err) + end + local identity = json.decode(account:identity_keys()) + -- TODO figure out what device id is supposed to be + olmdata.device_id = identity.ed25519:match'%w*' -- problems with nonalfanum + olmdata.device_key = identity.curve25519 + w.print('', 'matrix: Encryption loaded. To send encrypted messages in a room, use command /encrypt on with a room as active current buffer') + if DEBUG then + dbg{olm={ + 'Loaded identity:', + json.decode(account:identity_keys()) + }} + end + return olmdata +end + +function Olm:save() + -- Save account and every pickled session + local pickle, err = self.account:pickle(OLM_KEY) + perr(err) + local fd = io.open(HOMEDIR..'account.olm', 'wb') + fd:write(pickle) + fd:close() + --for key, pickled in pairs(self.sessions) do + -- local user_id, device_id = key:match('(.*):(.+)') + -- self.write_session_to_file(pickled, user_id, device_id) + --end +end + +function Olm:query(user_ids) -- Query keys from other user_id + if DEBUG then + perr('olm: querying user_ids') + tprint(user_ids) + end + local auth = urllib.urlencode{access_token=SERVER.access_token} + local data = { + device_keys = {} + } + for _, uid in pairs(user_ids) do + data.device_keys[uid] = {false} + end + http('/keys/query/?'..auth, + {postfields=json.encode(data)}, + 'http_cb', + 5*1000, nil, + v2_api_ns + ) +end + +function Olm:check_server_keycount() + local data = urllib.urlencode{access_token=SERVER.access_token} + http('/keys/upload/'..self.device_id..'?'..data, + {}, + 'http_cb', 5*1000, nil, v2_api_ns + ) +end + +function Olm:upload_keys() + if DEBUG then + perr('olm: Uploading keys') + end + local id_keys = json.decode(self.account:identity_keys()) + local user_id = SERVER.user_id + local one_time_keys = {} + local otks = json.decode(self.account:one_time_keys()) + local keyCount = 0 + for id, k in pairs(otks.curve25519) do + keyCount = keyCount + 1 + end + perr('olm: keycount: '..tostring(keyCount)) + if keyCount < 5 then -- try to always have 5 keys + perr('olm: newly generated keys: '..tostring(tonumber( + self.account:generate_one_time_keys(5 - keyCount)))) + otks = json.decode(self.account:one_time_keys()) + end + + for id, key in pairs(otks.curve25519) do + one_time_keys['curve25519:'..id] = key + keyCount = keyCount + 1 + end + + -- Construct JSON manually so it's ready for signing + local keys_json = '{"algorithms":["' .. OLM_ALGORITHM .. '"]' + .. ',"device_id":"' .. self.device_id .. '"' + .. ',"keys":' + .. '{"ed25519:' .. self.device_id .. '":"' + .. id_keys.ed25519 + .. '","curve25519:' .. self.device_id .. '":"' + .. id_keys.curve25519 + .. '"}' + .. ',"user_id":"' .. user_id + .. '"}' + + local success, key_data = pcall(json.decode, keys_json) + -- TODO save key data to device_keys so we don't have to download + -- our own keys from the servers? + if not success then + perr(('olm: upload_keys: %s when converting to json: %s') + :format(key_data, keys_json)) + end + + local msg = { + device_keys = key_data, + one_time_keys = one_time_keys + } + msg.device_keys.signatures = { + [user_id] = { + ["ed25519:"..self.device_id] = self.account:sign(keys_json) + } + } + local data = urllib.urlencode{ + access_token = SERVER.access_token + } + http('/keys/upload/'..self.device_id..'?'..data, { + postfields = json.encode(msg) + }, 'http_cb', 5*1000, nil, v2_api_ns) + + self.account:mark_keys_as_published() + +end + +function Olm:claim(user_id, device_id) -- Fetch one time keys + if DEBUG then + perr(('olm: Claiming OTK for user: %s and device: %s'):format(user_id, device_id)) + end + -- TODO take a list of ids for batch downloading + local auth = urllib.urlencode{ access_token = SERVER.access_token } + local data = { + one_time_keys = { + [user_id] = { + [device_id] = 'curve25519' + } + } + } + http('/keys/claim?'..auth, + {postfields=json.encode(data)}, + 'http_cb', 30*1000, nil, v2_api_ns + ) +end + +function Olm:create_session(user_id, device_id) + perr(('olm: creating session for user: %s, and device: %s'):format(user_id, device_id)) + local device_data = self.device_keys[user_id][device_id] + if not device_data then + perr(('olm: missing device data for user: %s, and device: %s'):format(user_id, device_id)) + return + end + local device_key = device_data.keys['curve25519:'..device_id] + if not device_key then + perr("olm: Missing key for user: "..user_id.." and device: "..device_id.."") + return + end + local sessions = self:get_sessions(device_key) + if not sessions[device_key] then + perr(('olm: creating NEW session for: %s, and device: %s'):format(user_id, device_id)) + local session = olm.Session.new() + local otk = self.otks[user_id..':'..device_id] + if not otk then + perr("olm: Missing OTK for user: "..user_id.." and device: "..device_id.."") + else + otk = otk[device_id] + end + if otk then + session:create_outbound(self.account, device_key, otk) + local session_id = session:session_id() + perr('olm: Session ID:'..tostring(session_id)) + self:store_session(device_key, session) + end + session:clear() + end +end + +function Olm:get_sessions(device_key) + if DEBUG then + perr("olm: get_sessions: device: "..device_key.."") + end + local sessions = self.sessions[device_key] + if not sessions then + sessions = self:read_session(device_key) + end + return sessions +end + +function Olm:read_session(device_key) + local session_filename = HOMEDIR..device_key..'.session.olm' + local fd, err = io.open(session_filename, 'rb') + if fd then + perr(('olm: reading saved session device: %s'):format(device_key)) + local sessions = fd:read'*all' + sessions = json.decode(sessions) + self.sessions[device_key] = sessions + fd:close() + return sessions + else + perr(('olm: Error: %s, reading saved session device: %s'):format(err, device_key)) + end + return {} +end + +function Olm:store_session(device_key, session) + local session_id = session:session_id() + if DEBUG then + perr("olm: store_session: device: "..device_key..", Session ID: "..session_id) + end + local sessions = self.sessions[device_key] or {} + local pickled = session:pickle(OLM_KEY) + sessions[session_id] = pickled + self.sessions[device_key] = sessions + self:write_session_to_file(sessions, device_key) +end + +function Olm:write_session_to_file(sessions, device_key) + local session_filename = HOMEDIR..device_key..'.session.olm' + local fd, err = io.open(session_filename, 'wb') + if fd then + fd:write(json.encode(sessions)) + fd:close() + else + perr('olm: error saving session: '..tostring(err)) + end +end + +MatrixServer = {} +MatrixServer.__index = MatrixServer + +MatrixServer.create = function() + local server = {} + setmetatable(server, MatrixServer) + server.nick = nil + server.connecting = false + server.connected = false + server.rooms = {} + -- Store user presences here since they are not local to the rooms + server.presence = {} + server.end_token = 'END' + server.typing_time = os.time() + server.typingtimer = w.hook_timer(10*1000, 0, 0, "cleartyping", "") + + -- Use a lock to prevent multiple simul poll with same end token, which + -- could lead to duplicate messages + server.poll_lock = false + server.olm = Olm.create() + if server.olm then -- might not be available + -- Run save so we do not lose state. Create might create new account, + -- new keys, etc. + server.olm:save() + end + return server +end + +function MatrixServer:UpdatePresence(c) + local user_id = c.sender or c.content.user_id + self.presence[user_id] = c.content.presence + for id, room in pairs(self.rooms) do + room:UpdatePresence(c.sender, c.content.presence) + end +end + +function MatrixServer:findRoom(buffer_ptr) + for id, room in pairs(self.rooms) do + if room.buffer == buffer_ptr then + return room + end + end +end + +function MatrixServer:connect() + if not self.connecting then + local user = weechat_eval(w.config_get_plugin('user')) + local password = weechat_eval(w.config_get_plugin('password')) + if user == '' or password == '' then + w.print('', 'Please set your username and password using the settings system and then type /matrix connect') + return + end + + self.connecting = true + w.print('', 'matrix: Connecting to homeserver URL: '.. + w.config_get_plugin('homeserver_url')) + local post = { + ["type"]="m.login.password", + ["user"]=user, + ["password"]=password + } + http('/login', { + postfields = json.encode(post) + }, 'http_cb', 5*1000) -- Set a short timeout so user can get more immidiate feedback + end +end + +function MatrixServer:initial_sync() + BUFFER = w.buffer_new(SCRIPT_NAME, "", "", "closed_matrix_buffer_cb", "") + w.buffer_set(BUFFER, "short_name", SCRIPT_NAME) + w.buffer_set(BUFFER, "name", SCRIPT_NAME) + w.buffer_set(BUFFER, "localvar_set_type", "server") + w.buffer_set(BUFFER, "localvar_set_server", SCRIPT_NAME) + w.buffer_set(BUFFER, "title", ("Matrix: %s"):format( + w.config_get_plugin'homeserver_url')) + if w.config_string(w.config_get('irc.look.server_buffer')) == 'merge_with_core' then + w.buffer_merge(BUFFER, w.buffer_search_main()) + end + w.buffer_set(BUFFER, "display", "auto") + local data = urllib.urlencode({ + access_token = self.access_token, + timeout = 1000*POLL_INTERVAL, + full_state = 'true', + filter = json.encode({ -- timeline filter + room = { + timeline = { + limit = tonumber(w.config_get_plugin('backlog_lines')) + } + }, + presence = { + not_types = {'*'}, -- dont want presence + } + }) + }) + local extra = 'initial' + -- New v2 sync API is slow. Until we can easily ignore archived rooms + -- let's increase the timer for the initial login + local login_timer = 60*5*1000 + http('/sync?'..data, nil, 'http_cb', login_timer, extra) +end + +function MatrixServer:post_initial_sync() + -- Timer used in cased of errors to restart the polling cycle + -- During normal operation the polling should re-invoke itself + SERVER.polltimer = w.hook_timer(POLL_INTERVAL*1000, 0, 0, "polltimer_cb", "") + if olmstatus then + -- timer that checks number of otks available on the server + SERVER.otktimer = w.hook_timer(5*60*1000, 0, 0, "otktimer_cb", "") + SERVER.olm:query{SERVER.user_id} + --SERVER.olm.upload_keys() + SERVER.olm:check_server_keycount() + end +end + +function MatrixServer:getMessages(room_id, dir, from, limit) + if not dir then dir = 'b' end + if not from then from = 'END' end + if not limit then limit = w.config_get_plugin('backlog_lines') end + local data = urllib.urlencode({ + access_token = self.access_token, + dir = dir, + from = from, + limit = limit, + }) + http(('/rooms/%s/messages?%s') + :format(urllib.quote(room_id), data), nil, nil, nil, room_id) +end + +function MatrixServer:Join(room) + if not self.connected then + --XXX''' + return + end + + mprint('\tJoining room '..room) + room = urllib.quote(room) + http('/join/' .. room, + {postfields = "access_token="..self.access_token}) +end + +function MatrixServer:part(room) + if not self.connected then + --XXX''' + return + end + + local id = urllib.quote(room.identifier) + local data = urllib.urlencode({ + access_token= self.access_token, + }) + http(('/rooms/%s/leave?%s'):format(id, data), {postfields = "{}"}, + 'http_cb', 10000, room.identifier) +end + +function MatrixServer:poll() + if self.connected == false then + return + end + if self.poll_lock then + return + end + self.poll_lock = true + self.polltime = os.time() + local filter = {} + if w.config_get_plugin('presence_filter') == 'on' then + filter = { -- timeline filter + presence = { + not_types = {'*'}, -- dont want presence + }, + room = { + ephemeral = { + not_types = {'*'}, -- dont want read receipt and typing notices + } + } + } + end + local data = urllib.urlencode({ + access_token = self.access_token, + timeout = 1000*POLL_INTERVAL, + full_state = 'false', + filter = json.encode(filter), + since = self.end_token + }) + http('/sync?'..data, nil, 'http_cb', (POLL_INTERVAL+10)*1000) +end + +function MatrixServer:addRoom(room) + -- Just in case, we check for duplicates here + if self.rooms[room['room_id']] then + return self.rooms[room['room_id']] + end + local myroom = Room.create(room) + myroom:create_buffer() + self.rooms[room['room_id']] = myroom + return myroom +end + +function MatrixServer:delRoom(room_id) + for id, room in pairs(self.rooms) do + if id == room_id then + mprint('\tLeft room '..room.name) + room:destroy() + self.rooms[id] = nil + break + end + end +end + +function MatrixServer:SendReadReceipt(room_id, event_id) + -- TODO: prevent sending multiple identical read receipts + local r_type = 'm.read' + local auth = urllib.urlencode{access_token=self.access_token} + room_id = urllib.quote(room_id) + event_id = urllib.quote(event_id) + local url = '/rooms/'..room_id..'/receipt/'..r_type..'/'..event_id..'?'..auth + http(url, + {customrequest = 'POST'}, + 'http_cb', + 5*1000 + ) +end + +function MatrixServer:Msg(room_id, body, msgtype, url) + -- check if there's an outgoing message timer already + self:ClearSendTimer() + + if not msgtype then + msgtype = 'm.text' + end + + if not OUT[room_id] then + OUT[room_id] = {} + end + -- Add message to outgoing queue of messages for this room + table.insert(OUT[room_id], {msgtype, body, url}) + + self:StartSendTimer() +end + +function MatrixServer:StartSendTimer() + local send_delay = 50 -- Wait this long for paste detection + self.sendtimer = w.hook_timer(send_delay, 0, 1, "send", "") +end + +function MatrixServer:ClearSendTimer() + -- Clear timer if it exists + if self.sendtimer then + w.unhook(self.sendtimer) + end + self.sendtimer = nil +end + +function send(cbdata, calls) + SERVER:ClearSendTimer() + -- Find the room + local room + + for id, msgs in pairs(OUT) do + -- Clear message + OUT[id] = nil + local body = {} + local htmlbody = {} + local msgtype + local url + + local ishtml = false + + for _, r in pairs(SERVER.rooms) do + if r.identifier == id then + room = r + break + end + end + + for _, msg in pairs(msgs) do + -- last msgtype will override any other for simplicity's sake + msgtype = msg[1] + local html = irc_formatting_to_html(msg[2]) + if html ~= msg[2] then + ishtml = true + end + table.insert(htmlbody, html ) + table.insert(body, msg[2] ) + if msg[3] then -- Primarily image upload + url = msg[3] + end + end + body = table.concat(body, '\n') + + -- Run IRC modifiers (XXX: maybe run out1 also? + body = w.hook_modifier_exec('irc_out1_PRIVMSG', '', body) + + if w.config_get_plugin('local_echo') == 'on' or + room.encrypted then + -- Generate local echo + local color = default_color + if msgtype == 'm.text' then + --- XXX: no localecho for encrypted messages? + local tags = 'notify_none,localecho,no_highlight' + if room.encrypted then + tags = tags .. ',no_log' + color = w.color(w.config_get_plugin( + 'encrypted_message_color')) + end + w.print_date_tags(room.buffer, nil, + tags, ("%s\t%s%s"):format( + room:formatNick(SERVER.user_id), + color, + irc_formatting_to_weechat_color(body) + ) + ) + elseif msgtype == 'm.emote' then + local prefix_c = wcolor'weechat.color.chat_prefix_action' + local prefix = wconf'weechat.look.prefix_action' + local tags = 'notify_none,localecho,irc_action,no_highlight' + if room.encrypted then + tags = tags .. ',no_log' + color = w.color(w.config_get_plugin( + 'encrypted_message_color')) + end + w.print_date_tags(room.buffer, nil, + tags, ("%s%s\t%s%s%s %s"):format( + prefix_c, + prefix, + w.color('chat_nick_self'), + room.users[SERVER.user_id], + color, + irc_formatting_to_weechat_color(body) + ) + ) + end + end + + local data = { + postfields = { + msgtype = msgtype, + body = body, + url = url, + }} + + if ishtml then + htmlbody = table.concat(htmlbody, '\n') + data.postfields.body = strip_irc_formatting(body) + data.postfields.format = 'org.matrix.custom.html' + data.postfields.formatted_body = htmlbody + end + + local api_event_function = 'm.room.message' + + if olmstatus and room.encrypted then + api_event_function = 'm.room.encrypted' + local olmd = SERVER.olm + + data.postfields.algorithm = OLM_ALGORITHM + data.postfields.sender_key = olmd.device_key + data.postfields.ciphertext = {} + + -- Count number of devices we are sending to + local recipient_count = 0 + + for user_id, _ in pairs(room.users) do + for device_id, device_data in pairs(olmd.device_keys[user_id] or {}) do -- FIXME check for missing keys? + + local device_key + -- TODO save this better somehow? + for key_id, key_data in pairs(device_data.keys) do + if key_id:match('^curve25519') then + device_key = key_data + end + end + local sessions = olmd:get_sessions(device_key) + -- Use the session with the lowest ID + -- TODO: figure out how to pick session better? + table.sort(sessions) + local pickled = next(sessions) + if pickled then + local session = olm.Session.new() + session:unpickle(OLM_KEY, pickled) + local session_id = session:session_id() + perr(('Session ID: %s, user_id: %s, device_id: %s'): + format(session_id, user_id, device_id)) + local payload = { + room_id = room.identifier, + ['type'] = "m.room.message", + fingerprint = "", -- TODO: Olm:sha256 participants + sender_device = olmd.device_id, + content = { + msgtype = msgtype, + body = data.postfields.body or '', + url = url + } + } + -- encrypt body + local mtype, e_body = session:encrypt(json.encode(payload)) + local ciphertext = { + ["type"] = mtype, + body = e_body + } + data.postfields.ciphertext[device_key] = ciphertext + recipient_count = recipient_count + 1 + + -- Save session + olmd:store_session(device_key, session) + session:clear() + end + end + end + -- remove cleartext from original msg + data.postfields.body = nil + data.postfields.formatted_body = nil + + if recipient_count == 0 then + perr('Aborted sending of encrypted message: could not find any valid recipients') + return + end + end + + data.postfields = json.encode(data.postfields) + data.customrequest = 'PUT' + + http(('/rooms/%s/send/%s/%s?access_token=%s') + :format( + urllib.quote(id), + api_event_function, + get_next_transaction_id(), + urllib.quote(SERVER.access_token) + ), + data, + nil, + nil, + id -- send room id to extra + ) + end +end + +function MatrixServer:emote(room_id, body) + self:Msg(room_id, body, 'm.emote') +end + +function MatrixServer:notice(room_id, body) + self:Msg(room_id, body, 'm.notice') +end + +function MatrixServer:state(room_id, key, data) + http(('/rooms/%s/state/%s?access_token=%s') + :format(urllib.quote(room_id), + urllib.quote(key), + urllib.quote(self.access_token)), + {customrequest = 'PUT', + postfields = json.encode(data), + }) +end + +function MatrixServer:set_membership(room_id, userid, data) + http(('/rooms/%s/state/m.room.member/%s?access_token=%s') + :format(urllib.quote(room_id), + urllib.quote(userid), + urllib.quote(self.access_token)), + {customrequest = 'PUT', + postfields = json.encode(data), + }) +end + +function MatrixServer:SendPresence(p, status_msg) + -- One of: ["online", "offline", "unavailable", "free_for_chat"] + local data = { + presence = p, + status_msg = status_msg + } + http(('/presence/%s/status?access_token=%s') + :format( + urllib.quote(self.user_id), + urllib.quote(self.access_token)), + {customrequest = 'PUT', + postfields = json.encode(data), + }) +end + +function MatrixServer:SendTypingNotice(room_id) + local data = { + typing = true, + timeout = 4*1000 + } + http(('/rooms/%s/typing/%s?access_token=%s') + :format(urllib.quote(room_id), + urllib.quote(self.user_id), + urllib.quote(self.access_token)), + {customrequest = 'PUT', + postfields = json.encode(data), + }) +end + +function MatrixServer:Upload(room_id, filename) + local content_type = 'image/jpeg' + if filename:match'%.[Pp][nN][gG]$' then + content_type = 'image/png' + end + local url = w.config_get_plugin('homeserver_url') .. + ('_matrix/media/r0/upload?access_token=%s') + :format( urllib.quote(SERVER.access_token) ) + w.hook_process_hashtable('curl', { + arg1 = '--data-binary', -- no encoding of data + arg2 = '@'..filename, -- @means curl will load the filename + arg3 = '-XPOST', -- HTTP POST method + arg4 = '-H', -- header + arg5 = 'Content-Type: '..content_type, + arg6 = '-s', -- silent + arg7 = url, + }, 30*1000, 'upload_cb', room_id) +end + +function MatrixServer:CreateRoom(public, alias, invites) + local data = {} + if alias then + data.room_alias_name = alias + end + if public then + data.visibility = 'public' + else + data.visibility = 'private' + end + if invites then + data.invite = invites + end + http(('/createRoom?access_token=%s') + :format(urllib.quote(self.access_token)), + {customrequest = 'POST', + postfields = json.encode(data), + }) +end + +function MatrixServer:CreateRoomAlias(room_id, alias) + local data = {room_id = room_id} + alias = urllib.quote(alias) + http(('/directory/room/%s?access_token=%s') + :format(alias, urllib.quote(self.access_token)), + {customrequest = 'PUT', + postfields = json.encode(data), + }) +end + +function MatrixServer:ListRooms(arg) + local apipart = ('/publicRooms?access_token=%s'):format(urllib.quote(self.access_token)) + if arg then + local url = 'https://' .. arg .. "/_matrix/client/r0" + http(url..apipart) + else + http(apipart) + end +end + +function MatrixServer:Invite(room_id, user_id) + local data = { + user_id = user_id + } + http(('/rooms/%s/invite?access_token=%s') + :format(urllib.quote(room_id), + urllib.quote(self.access_token)), + {customrequest = 'POST', + postfields = json.encode(data), + }) +end + +function MatrixServer:Nick(displayname) + local data = { + displayname = displayname, + } + http(('/profile/%s/displayname?access_token=%s') + :format( + urllib.quote(self.user_id), + urllib.quote(self.access_token)), + {customrequest = 'PUT', + postfields = json.encode(data), + }) +end + +function buffer_input_cb(b, buffer, data) + for r_id, room in pairs(SERVER.rooms) do + if buffer == room.buffer then + SERVER:Msg(r_id, data) + break + end + end + return w.WEECHAT_RC_OK +end + +Room = {} +Room.__index = Room +Room.create = function(obj) + local room = {} + setmetatable(room, Room) + room.buffer = nil + room.identifier = obj['room_id'] + local _, server = room.identifier:match('^(.*):(.+)$') + room.server = server + room.member_count = 0 + -- Cache users for presence/nicklist + room.users = {} + -- Table of ids currently typing + room.typing_ids = {} + -- Cache the rooms power levels state + room.power_levels = {users={}, users_default=0} + -- Encryption status of room + room.encrypted = false + room.visibility = 'public' + room.join_rule = nil + room.roomname = nil -- m.room.name + room.aliases = nil -- aliases + room.canonical_alias = nil + + -- We might not be a member yet + local state_events = obj.state or {} + for _, state in ipairs(state_events) do + if state['type'] == 'm.room.aliases' then + local name = state.content.aliases[1] + if name then + room.name, _ = name:match('(.+):(.+)') + end + end + end + if not room.name then + room.name = room.identifier + end + if not room.server then + room.server = 'matrix' + end + + room.visibility = obj.visibility + if not obj['visibility'] then + room.visibility = 'public' + end + + return room +end + +function Room:SetName(name) + if not name or name == '' or name == json.null then + return + end + -- override hierarchy + if self.roomname and self.roomname ~= '' then + name = self.roomname + elseif self.canonical_alias then + name = self.canonical_alias + local short_name, _ = self.canonical_alias:match('^(.-):(.+)$') + if short_name then + name = short_name + end + elseif self.aliases then + local alias = self.aliases[1] + if name then + local _ + name, _ = alias:match('(.+):(.+)') + end + else + -- NO names. Set dynamic name based on members + local new = {} + for id, nick in pairs(self.users) do + -- Set the name to the other party + if id ~= SERVER.user_id then + new[#new+1] = nick + end + end + name = table.concat(new, ',') + end + + if not name or name == '' or name == json.null then + return + end + + -- Check for dupe + local buffer_name = w.buffer_get_string(self.buffer, 'name') + if buffer_name == name then + return + end + + + w.buffer_set(self.buffer, "short_name", name) + w.buffer_set(self.buffer, "name", name) + -- Doesn't work + w.buffer_set(self.buffer, "plugin", "matrix") + w.buffer_set(self.buffer, "full_name", + self.server.."."..name) + w.buffer_set(self.buffer, "localvar_set_channel", name) +end + +function Room:Topic(topic) + SERVER:state(self.identifier, 'm.room.topic', {topic=topic}) +end + +function Room:Name(name) + SERVER:state(self.identifier, 'm.room.name', {name=name}) +end + +function Room:public() + SERVER:state(self.identifier, 'm.room.join_rules', {join_rule='public'}) +end + +function Room:Upload(filename) + SERVER:Upload(self.identifier, filename) +end + +function Room:Msg(msg) + SERVER:Msg(self.identifier, msg) +end + +function Room:emote(msg) + SERVER:emote(self.identifier, msg) +end + +function Room:Notice(msg) + SERVER:notice(self.identifier, msg) +end + +function Room:SendTypingNotice() + SERVER:SendTypingNotice(self.identifier) +end + +function Room:create_buffer() + --local buffer = w.buffer_search("", ("%s.%s"):format(self.server, self.name)) + self.buffer = w.buffer_new(("%s.%s") + :format(self.server, self.name), "buffer_input_cb", + self.name, "closed_matrix_room_cb", "") + -- Needs to correspond with return values from Room:GetNickGroup() + -- We will use 5 nick groups: + -- 1: Ops + -- 2: Half-ops + -- 3: Voice + -- 4: People with presence + -- 5: People without presence + self.nicklist_groups = { + -- Emulate OPs + w.nicklist_add_group(self.buffer, + '', "000|o", "weechat.color.nicklist_group", 1), + w.nicklist_add_group(self.buffer, + '', "001|h", "weechat.color.nicklist_group", 1), + -- Emulate half-op + w.nicklist_add_group(self.buffer, + '', "002|v", "weechat.color.nicklist_group", 1), + -- Defined in weechat's irc-nick.h + w.nicklist_add_group(self.buffer, + '', "998|...", "weechat.color.nicklist_group", 1), + w.nicklist_add_group(self.buffer, + '', "999|...", "weechat.color.nicklist_group", 1), + } + w.buffer_set(self.buffer, "nicklist", "1") + -- Set to 1 for easier debugging of nick groups + w.buffer_set(self.buffer, "nicklist_display_groups", "0") + w.buffer_set(self.buffer, "localvar_set_server", self.server) + w.buffer_set(self.buffer, "localvar_set_roomid", self.identifier) + self:SetName(self.name) + if self.membership == 'invite' then + self:addNick(self.inviter) + if w.config_get_plugin('autojoin_on_invite') ~= 'on' then + w.print_date_tags( + self.buffer, + nil, + 'notify_message', + ('You have been invited to join room %s by %s. Type /join in this buffer to join.') + :format( + self.name, + self.inviter, + self.identifier) + ) + end + end +end + +function Room:Freeze() + -- Function that saves all the lines in a buffer in a cache to be thawed + -- later. Used to redraw buffer when user requests more lines. Since + -- WeeChat can only render lines in order this is the workaround + local freezer = {} + local lines = w.hdata_pointer(w.hdata_get('buffer'), self.buffer, 'own_lines') + if lines == '' then return end + -- Start at top + local line = w.hdata_pointer(w.hdata_get('lines'), lines, 'first_line') + if line == '' then return end + local hdata_line = w.hdata_get('line') + local hdata_line_data = w.hdata_get('line_data') + while #line > 0 do + local data = w.hdata_pointer(hdata_line, line, 'data') + local tags = {} + local tag_count = w.hdata_integer(hdata_line_data, data, "tags_count") + if tag_count > 0 then + for i = 0, tag_count-1 do + local tag = w.hdata_string(hdata_line_data, data, i .. "|tags_array") + -- Skip notify tags since this is backlog + if not tag:match'^notify' then + tags[#tags+1] = tag + end + end + end + tags[#tags+1] = 'no_log' + freezer[#freezer+1] = { + time = w.hdata_integer(hdata_line_data, data, 'time'), + tags = tags, + prefix = w.hdata_string(hdata_line_data, data, 'prefix'), + message = w.hdata_string(hdata_line_data, data, 'message'), + } + -- Move forward since we start at top + line = w.hdata_move(hdata_line, line, 1) + end + self.freezer = freezer +end + +function Room:Thaw() + for _,l in ipairs(self.freezer) do + w.print_date_tags( + self.buffer, + l.time, + table.concat(l.tags, ','), + l.prefix .. '\t' .. l.message + ) + end + -- Clear old data + self.freezer = nil +end + +function Room:Clear() + w.buffer_clear(self.buffer) +end + +function Room:destroy() + w.buffer_close(self.buffer) +end + +function Room:_nickListChanged() + -- Check the user count, if it's 2 or less then we decide this buffer + -- is a "private" one like IRC's query type + if self.member_count == 3 then + w.buffer_set(self.buffer, "localvar_set_type", 'channel') + self.buffer_type = 'channel' + elseif self.member_count == 2 then + -- At the point where we reach two nicks, set the buffer name to be + -- the display name of the other guy that is not our self since it's + -- in effect a query, but the matrix protocol doesn't have such + -- a concept + w.buffer_set(self.buffer, "localvar_set_type", 'private') + self.buffer_type = 'query' + elseif self.member_count == 1 then + if not self.roomname and not self.aliases then + -- Set the name to ourselves + self:SetName(self.users[SERVER.user_id]) + end + end +end + +function Room:addNick(user_id, displayname) + local newnick = false + -- Sanitize displaynames a bit + if not displayname + or displayname == json.null + or displayname == '' + or displayname:match'^%s+$' then + displayname = user_id:match('@(.*):.+') + end + if not self.users[user_id] then + self.member_count = self.member_count + 1 + newnick = true + end + + if self.users[user_id] ~= displayname then + self.users[user_id] = displayname + end + + local nick_c = self:GetPresenceNickColor(user_id, SERVER.presence[user_id]) + -- Check if this is ourselves + if user_id == SERVER.user_id then + w.buffer_set(self.buffer, "highlight_words", displayname) + w.buffer_set(self.buffer, "localvar_set_nick", displayname) + end + + local ngroup, nprefix, nprefix_color = self:GetNickGroup(user_id) + -- Check if nick already exists + --local nick_ptr = w.nicklist_search_nick(self.buffer, '', displayname) + --if nick_ptr == '' then + local nick_ptr = w.nicklist_add_nick(self.buffer, + self.nicklist_groups[ngroup], + displayname, + nick_c, nprefix, nprefix_color, 1) + --else + -- -- TODO CHANGE nickname here + --end + if nick_ptr == '' then + -- Duplicate nick names :( + -- We just add the full id to the nicklist so atleast it will show + -- but we should probably assign something new and track the state + -- so we can print msgs with non-conflicting nicks too + w.nicklist_add_nick(self.buffer, + self.nicklist_groups[ngroup], + user_id, + nick_c, nprefix, nprefix_color, 1) + -- Since we can't allow duplicate displaynames, we just use the + -- user_id straight up. Maybe we could invent some clever + -- scheme here, like user(homeserver), user (2) or something + self.users[user_id] = user_id + end + + if newnick then -- run this after nick been added so it can be used + self:_nickListChanged() + end + + return displayname +end + +function Room:GetNickGroup(user_id) + -- TODO, cache + local ngroup = 5 + local nprefix = ' ' + local nprefix_color = '' + if self:GetPowerLevel(user_id) >= 100 then + ngroup = 1 + nprefix = '&' + nprefix_color = 'lightgreen' + if user_id == self.creator then + nprefix = '~' + nprefix_color = 'lightred' + end + elseif self:GetPowerLevel(user_id) >= 50 then + ngroup = 2 + nprefix = '@' + nprefix_color = 'lightgreen' + elseif self:GetPowerLevel(user_id) > 0 then + ngroup = 3 + nprefix = '+' + nprefix_color = 'yellow' + elseif SERVER.presence[user_id] then + -- User has a presence, put him in group3 + ngroup = 4 + end + return ngroup, nprefix, nprefix_color +end + +function Room:GetPowerLevel(user_id) + return tonumber(self.power_levels.users[user_id] or self.power_levels.users_default or 0) +end + +function Room:ClearTyping() + for user_id, nick in pairs(self.users) do + local _, nprefix, nprefix_color = self:GetNickGroup(user_id) + self:UpdateNick(user_id, 'prefix', nprefix) + self:UpdateNick(user_id, 'prefix_color', nprefix_color) + end +end + +function Room:GetPresenceNickColor(user_id, presence) + local nick = self.users[user_id] + local nick_c + if user_id == SERVER.user_id then + -- Always use correct color for self + nick_c = 'weechat.color.chat_nick_self' + elseif presence == 'online' then + nick_c = w.info_get('irc_nick_color_name', nick) + elseif presence == 'unavailable' then + nick_c = 'weechat.color.nicklist_away' + elseif presence == 'offline' then + nick_c = 'red' + elseif presence == nil then + nick_c = 'bar_fg' + else + dbg{err='unknown presence type',presence=presence} + end + return nick_c +end + +function Room:UpdatePresence(user_id, presence) + if presence == 'typing' then + self:UpdateNick(user_id, 'prefix', '!') + self:UpdateNick(user_id, 'prefix_color', 'magenta') + return + end + local nick_c = self:GetPresenceNickColor(user_id, presence) + self:UpdateNick(user_id, 'color', nick_c) +end + +function Room:UpdateNick(user_id, key, val) + local nick = self.users[user_id] + if not nick then return end + local nick_ptr = w.nicklist_search_nick(self.buffer, '', nick) + + if nick_ptr ~= '' and key and val then + -- Check if we need to move the nick into another group + local group_ptr = w.nicklist_nick_get_pointer(self.buffer, nick_ptr, + 'group') + local ngroup, nprefix, nprefix_color = self:GetNickGroup(user_id) + if group_ptr ~= self.nicklist_groups[ngroup] then + local nick_c = w.nicklist_nick_get_string(self.buffer, nick_ptr, + 'color') + -- No WeeChat API for changing a nick's group so we will have to + -- delete the nick from the old nicklist and add it to the correct + -- nicklist group + w.nicklist_remove_nick(self.buffer, nick_ptr) + -- TODO please check if this call fails, if it does it means the + -- WeeChat version is old and has a bug so it can't remove nicks + -- and so it needs some workaround + nick_ptr = w.nicklist_add_nick(self.buffer, + self.nicklist_groups[ngroup], + nick, + nick_c, nprefix, nprefix_color, 1) + end + -- Check if we are clearing a typing notice, and don't issue updates + -- if we are, because it spams the API so much, including potential + -- relay clients + if key == 'prefix' and val == ' ' then + -- TODO check existing values like + and @ too + local prefix = w.nicklist_nick_get_string(self.buffer, nick_ptr, + key) + if prefix == '!' then + w.nicklist_nick_set(self.buffer, nick_ptr, key, val) + end + elseif key == 'prefix_color' then + local prefix_color = w.nicklist_nick_get_string(self.buffer, + nick_ptr, key) + if prefix_color ~= val then + w.nicklist_nick_set(self.buffer, nick_ptr, key, val) + end + else + -- Check if we are actually updating something, so there's less + -- updates issued (I think WeeChat sends all changes as nicklist + -- diffs to both UI code and to relay clients + local existing = w.nicklist_nick_get_string(self.buffer, nick_ptr, key) + if val ~= existing then + w.nicklist_nick_set(self.buffer, nick_ptr, key, val) + end + end + end +end + +function Room:delNick(id) + if self.users[id] then + local nick = self.users[id] + local nick_ptr = w.nicklist_search_nick(self.buffer, '', nick) + if nick_ptr ~= '' then + w.nicklist_remove_nick(self.buffer, nick_ptr) + end + self.users[id] = nil + self.member_count = self.member_count - 1 + self:_nickListChanged() + return true + end +end + +function Room:UpdateLine(id, message) + local lines = w.hdata_pointer(w.hdata_get('buffer'), self.buffer, 'own_lines') + if lines == '' then return end + local line = w.hdata_pointer(w.hdata_get('lines'), lines, 'last_line') + if line == '' then return end + local hdata_line = w.hdata_get('line') + local hdata_line_data = w.hdata_get('line_data') + while #line > 0 do + local needsupdate = false + local data = w.hdata_pointer(hdata_line, line, 'data') + local tags = {} + local tag_count = w.hdata_integer(hdata_line_data, data, "tags_count") + if tag_count > 0 then + for i = 0, tag_count-1 do + local tag = w.hdata_string(hdata_line_data, data, i .. "|tags_array") + tags[#tags+1] = tag + if tag:match(id) then + needsupdate = true + end + end + if needsupdate then + w.hdata_update(hdata_line_data, data, { + prefix = nil, + message = message, + tags_array = table.concat(tags, ','), + }) + return true + end + end + line = w.hdata_move(hdata_line, line, -1) + end + return false +end + +function Room:formatNick(user_id) + -- Turns a nick name into a weechat-styled nickname. This means giving + -- it colors, and proper prefix and suffix + local nick = self.users[user_id] + if not nick then + return user_id + end + -- Remove nasty white space + nick = nick:gsub('[\n\t]', '') + local color + if user_id == SERVER.user_id then + color = w.color('chat_nick_self') + else + color = w.info_get('irc_nick_color', nick) + end + local _, nprefix, nprefix_c = self:GetNickGroup(user_id) + local prefix = wconf('weechat.look.nick_prefix') + local prefix_c = wcolor('weechat.color.chat_nick_prefix') + local suffix = wconf('weechat.look.nick_suffix') + local suffix_c = wcolor('weechat.color.chat_nick_suffix') + local nick_f = prefix_c + .. prefix + .. wcolor(nprefix_c) + .. nprefix + .. color + .. nick + .. suffix_c + .. suffix + return nick_f +end + +function Room:decryptChunk(chunk) + -- vector client doesn't provide this + chunk.content.msgtype = 'm.text' + + if not olmstatus then + chunk.content.body = 'encrypted message, unable to decrypt' + return chunk + end + + chunk.content.body = 'encrypted message, unable to decrypt' + local device_key = chunk.content.sender_key + -- Find our id + local ciphertexts = chunk.content.ciphertext + local ciphertext + if not ciphertexts then + chunk.content.body = 'Recieved an encrypted message, but could not find ciphertext array' + else + ciphertext = ciphertexts[SERVER.olm.device_key] + end + if not ciphertext then + chunk.content.body = 'Recieved an encrypted message, but could not find cipher for ourselves from the sender.' + return chunk + end + + local session + local decrypted + local err + local found_session = false + local sessions = SERVER.olm:get_sessions(device_key) + for id, pickle in pairs(sessions) do + -- Check if we already successfully decrypted with a sesssion, if that + -- is the case we break the loop + if decrypted then + break + end + session = olm.Session.new() + session:unpickle(OLM_KEY, pickle) + local matches_inbound = session:matches_inbound(ciphertext.body) + ---if ciphertext.type == 0 and matches_inbound then + if matches_inbound then + found_session = true + end + local cleartext + cleartext, err = session:decrypt(ciphertext.type, ciphertext.body) + if not err then + if DEBUG then + perr(('olm: Able to decrypt with an existing session %s'):format(session:session_id())) + end + decrypted = cleartext + SERVER.olm:store_session(device_key, session) + else + chunk.content.body = "Decryption error: "..err + if DEBUG then + perr(('olm: Unable to decrypt with an existing session: %s. Session-ID: %s'):format(err, session:session_id())) + end + end + session:clear() + end + if ciphertext.type == 0 and not found_session and not decrypted then + session = olm.Session.new() + local _ + _, err = session:create_inbound_from( + SERVER.olm.account, device_key, ciphertext.body) + if err then + session:clear() + chunk.content.body = "Decryption error: create inbound "..err + return chunk + end + decrypted, err = session:decrypt(ciphertext.type, ciphertext.body) + if err then + session:clear() + chunk.content.body = "Decryption error: "..err + return chunk + end + -- TODO SERVER.olm.account:remove_one_time_keys(session) + local session_id = session:session_id() + perr(('Session ID: %s, user_id: %s, device_id: %s'): + format(session_id, SERVER.user_id, SERVER.olm.device_id)) + SERVER.olm:store_session(device_key, session) + session:clear() + if err then + chunk.content.body = "Decryption error: "..err + return chunk + end + end + + if decrypted then + local success, payload = pcall(json.decode, decrypted) + if not success then + chunk.content.body = "Payload error: "..payload + return chunk + end + -- TODO use the room id from payload for security + chunk.content.msgtype = payload.content.msgtype + -- Style the message so user can tell if it's + -- an encrypted message or not + local color = w.color(w.config_get_plugin( + 'encrypted_message_color')) + chunk.content.body = color .. payload.content.body + end + + return chunk +end + +-- Parses a chunk of json meant for a room +function Room:ParseChunk(chunk, backlog, chunktype) + local taglist = {} + local tag = function(tag) + -- Helper function to add tags + if type(tag) == 'table' then + for _, t in ipairs(tag) do + taglist[t] = true + end + else + taglist[tag] = true + end + end + local tags = function() + -- Helper for returning taglist for this message + local out = {} + for k, v in pairs(taglist) do + table.insert(out, k) + end + return table.concat(out, ',') + end + if not backlog then + backlog = false + end + + if backlog then + tag{'no_highlight','notify_none','no_log'} + end + + local is_self = false + local was_decrypted = false + + -- Sender of chunk, used to be chunk.user_id, v2 uses chunk.sender + local sender = chunk.sender or chunk.user_id + -- Check if own message + if sender == SERVER.user_id then + is_self = true + tag{'no_highlight','notify_none'} + end + -- Add Event ID to each line so can use it later to match on for things + -- like redactions and localecho, etc + tag{chunk.event_id} + + -- Some messages are missing ts + local origin_server_ts = chunk['origin_server_ts'] or 0 + local time_int = origin_server_ts/1000 + + if chunk['type'] == 'm.room.message' or chunk['type'] == 'm.room.encrypted' then + if chunk['type'] == 'm.room.encrypted' then + tag{'no_log'} -- Don't log encrypted message + chunk = self:decryptChunk(chunk) + was_decrypted = true + end + + if not backlog and not is_self then + tag'notify_message' + if self.buffer_type == 'query' then + tag'notify_private' + end + end + + local color = default_color + local content = chunk['content'] + local body = content['body'] + + if not content['msgtype'] then + -- We don't support redactions + return + end + + -- If it has transaction id, it is from this client. + local is_from_this_client = false + if chunk.unsigned and chunk.unsigned.transaction_id then + is_from_this_client = true + end + + -- luacheck: ignore 542 + if content['msgtype'] == 'm.text' then + -- TODO + -- Parse HTML here: + -- content.format = 'org.matrix.custom.html' + -- fontent.formatted_body... + elseif content['msgtype'] == 'm.image' then + local url = content['url'] + if type(url) ~= 'string' then + url = '' + end + url = url:gsub('mxc://', + w.config_get_plugin('homeserver_url') + .. '_matrix/media/v1/download/') + -- Synapse homeserver supports arbitrary file endings, so we put + -- filename at the end to make it nicer for URL "sniffers" to + -- realise it's a image URL + body = url .. '/' .. content.body + elseif content.msgtype == 'm.file' or content.msgtype == 'm.video' or + content.msgtype == 'm.audio' then + local url = content['url'] or '' + url = url:gsub('mxc://', + w.config_get_plugin('homeserver_url') + .. '_matrix/media/v1/download/') + body = 'File upload: ' .. + tostring(content['body']) + .. ' ' .. url + elseif content['msgtype'] == 'm.notice' then + color = wcolor('irc.color.notice') + body = content['body'] + elseif content['msgtype'] == 'm.emote' then + local nick_c + local nick = self.users[sender] or sender + if is_self then + nick_c = w.color('chat_nick_self') + else + nick_c = w.info_get('irc_nick_color', nick) + end + tag"irc_action" + local prefix_c = wcolor'weechat.color.chat_prefix_action' + local prefix = wconf'weechat.look.prefix_action' + body = ("%s%s %s%s"):format( + nick_c, nick, color, content['body'] + ) + prefix = prefix_c .. prefix + local data = ("%s\t%s"):format(prefix, body) + if not backlog and is_self and is_from_this_client and + ( w.config_get_plugin('local_echo') == 'on' + or was_decrypted -- local echo for encryption + ) + then + -- We have already locally echoed this line + return + else + return w.print_date_tags(self.buffer, time_int, tags(), data) + end + else + -- Unknown content type, but if it contains an URL we will print + -- URL and body + local url = content['url'] + if url ~= nil then + url = url:gsub('mxc://', + w.config_get_plugin('homeserver_url') + .. '_matrix/media/v1/download/') + body = content['body'] .. ' ' .. url + end + dbg { + warning='Warning: unknown/unhandled content type', + event=content + } + end + if not backlog and is_self and is_from_this_client + -- TODO better check, to work for multiple weechat clients + and ( + w.config_get_plugin('local_echo') == 'on' + or was_decrypted -- local echo for encrypted messages + ) + and (-- we don't generate local echo for files and images + content.msgtype == 'm.text' + ) + then + -- We have already locally echoed this line + return + end + local data = ("%s\t%s%s"):format( + self:formatNick(sender), + color, + body) + w.print_date_tags(self.buffer, time_int, tags(), data) + elseif chunk['type'] == 'm.room.topic' then + local title = chunk['content']['topic'] + if not title then + title = '' + end + w.buffer_set(self.buffer, "title", title) + local color = wcolor("irc.color.topic_new") + local nick = self.users[sender] or sender + local data = ('--\t%s%s has changed the topic to "%s%s%s"'):format( + nick, + default_color, + color, + title, + default_color + ) + w.print_date_tags(self.buffer, chunk.origin_server_ts, tags(), + data) + elseif chunk['type'] == 'm.room.name' then + local name = chunk['content']['name'] + if name ~= '' or name ~= json.null then + self.roomname = name + self:SetName(name) + end + elseif chunk['type'] == 'm.room.member' then + if chunk['content']['membership'] == 'join' then + tag"irc_join" + --- FIXME shouldn't be neccessary adding all the time + local name = self.users[sender] or self:addNick(sender, chunk.content.displayname) + if not name or name == json.null or name == '' then + name = sender + end + -- Check if the chunk has prev_content or not + -- if there is prev_content there wasn't a join but a nick change + -- or duplicate join + local prev_content = chunk.unsigned and chunk.unsigned.prev_content + if prev_content + and prev_content.membership == 'join' + and chunktype == 'messages' then + local oldnick = prev_content.displayname + if not oldnick or oldnick == json.null then + oldnick = sender + else + if oldnick == name then + -- Maybe they changed their avatar or something else + -- that we don't care about (or multiple joins) + return + end + if not backlog then + self:delNick(sender) + self:addNick(sender, chunk.content.displayname) + end + end + local pcolor = wcolor'weechat.color.chat_prefix_network' + tag'irc_nick' + local data = ('%s--\t%s%s%s is now known as %s%s'):format( + pcolor, + w.info_get('irc_nick_color', oldnick), + oldnick, + default_color, + w.info_get('irc_nick_color', name), + name) + w.print_date_tags(self.buffer, time_int, tags(), data) + elseif chunktype == 'messages' then + tag"irc_smart_filter" + local data = ('%s%s\t%s%s%s (%s%s%s) joined the room.'):format( + wcolor('weechat.color.chat_prefix_join'), + wconf('weechat.look.prefix_join'), + w.info_get('irc_nick_color', name), + name, + wcolor('irc.color.message_join'), + wcolor'weechat.color.chat_host', + sender, + wcolor('irc.color.message_join') + ) + w.print_date_tags(self.buffer, time_int, tags(), data) + -- if this is an encrypted room, also download key + if olmstatus and self.encrypted then + SERVER.olm:query{sender} + end + end + elseif chunk['content']['membership'] == 'leave' then + if chunktype == 'messages' then + local nick = self.users[chunk.state_key] or sender + local prev = chunk.unsigned.prev_content + if (prev and + prev.displayname and + prev.displayname ~= json.null) then + nick = prev.displayname + end + if sender ~= chunk.state_key then -- Kick + tag{"irc_quit","irc_kick","irc_smart_filter"} + local reason = chunk.content.reason or '' + local sender_nick = self.users[chunk.sender] + local data = ('%s%s\t%s%s%s has kicked %s%s%s (%s).'):format( + wcolor('weechat.color.chat_prefix_quit'), + wconf('weechat.look.prefix_quit'), + w.info_get('irc_nick_color', sender_nick), + sender_nick, + wcolor('irc.color.message_quit'), + w.info_get('irc_nick_color', nick), + nick, + default_color, + reason + ) + w.print_date_tags(self.buffer, time_int, tags(), data) + else + tag{"irc_quit","irc_smart_filter"} + local data = ('%s%s\t%s%s%s left the room.'):format( + wcolor('weechat.color.chat_prefix_quit'), + wconf('weechat.look.prefix_quit'), + w.info_get('irc_nick_color', nick), + nick, + wcolor('irc.color.message_quit') + ) + w.print_date_tags(self.buffer, time_int, tags(), data) + end + end + self:delNick(chunk.state_key) + elseif chunk['content']['membership'] == 'invite' then + -- Check if we were the one being invited + if chunk.state_key == SERVER.user_id and ( + (not backlog and chunktype == 'messages') or + chunktype == 'states') then + --self:addNick(sender) + if w.config_get_plugin('autojoin_on_invite') == 'on' then + SERVER:Join(self.identifier) + mprint(('%s invited you'):format(sender)) + else + mprint(('You have been invited to join room %s by %s. Type /join %s to join.') + :format( + self.name, + sender, + self.identifier)) + end + end + if chunktype == 'messages' then + tag"irc_invite" + local prefix_c = wcolor'weechat.color.chat_prefix_action' + local prefix = wconf'weechat.look.prefix_action' + local data = ("%s%s\t%s invited %s to join"):format( + prefix_c, + prefix, + self.users[sender] or sender, + self.users[chunk.state_key] or chunk.state_key + ) + w.print_date_tags(self.buffer, time_int, tags(), data) + end + elseif chunk['content']['membership'] == 'ban' then + if chunktype == 'messages' then + tag"irc_ban" + local prefix_c = wcolor'weechat.color.chat_prefix_action' + local prefix = wconf'weechat.look.prefix_action' + local data = ("%s%s\t%s banned %s"):format( + prefix_c, + prefix, + self.users[sender] or sender, + self.users[chunk.state_key] or chunk.state_key + ) + w.print_date_tags(self.buffer, time_int, tags(), data) + end + else + dbg{err= 'unknown membership type in ParseChunk', chunk= chunk} + end + -- if it's backlog this is done at the end from the caller place + if not backlog then + -- Run SetName on each member change in case we need to update room name + self:SetName(self.identifier) + end + elseif chunk['type'] == 'm.room.create' then + self.creator = chunk.content.creator + elseif chunk['type'] == 'm.room.power_levels' then + if chunk.content.users then + self.power_levels = chunk.content + for user_id, lvl in pairs(self.power_levels.users) do + -- TODO + -- calculate changes here and generate message lines + -- describing the change + end + for user_id, lvl in pairs(self.power_levels.users) do + local _, nprefix, nprefix_color = self:GetNickGroup(user_id) + self:UpdateNick(user_id, 'prefix', nprefix) + self:UpdateNick(user_id, 'prefix_color', nprefix_color) + end + end + elseif chunk['type'] == 'm.room.join_rules' then + -- TODO: parse join_rules events -- + self.join_rules = chunk.content + elseif chunk['type'] == 'm.typing' then + -- Store the typing ids in a table that the bar item can use + local typing_ids = {} + for _, id in ipairs(chunk.content.user_ids) do + self:UpdatePresence(id, 'typing') + typing_ids[#typing_ids+1] = self.users[id] + end + self.typing_ids = typing_ids + w.bar_item_update('matrix_typing_notice') + elseif chunk['type'] == 'm.presence' then + SERVER:UpdatePresence(chunk) + elseif chunk['type'] == 'm.room.aliases' then + -- Use first alias, weechat doesn't really support multiple aliases + self.aliases = chunk.content.aliases + self:SetName(chunk.content.aliases[1]) + elseif chunk['type'] == 'm.room.canonical_alias' then + self.canonical_alias = chunk.content.alias + self:SetName(self.canonical_alias) + elseif chunk['type'] == 'm.room.redaction' then + local redact_id = chunk.redacts + --perr('Redacting message ' .. redact_id) + local result = self:UpdateLine(redact_id, w.color'darkgray'..'(redacted)') + if not result and not backlog then + -- backlog doesn't send original message + perr 'Could not find message to redact :(' + end + elseif chunk['type'] == 'm.room.history_visibility' then + self.history_visibility = chunk.content.history_visibility + -- luacheck: ignore 542 + elseif chunk['type'] == 'm.receipt' then + -- TODO: figure out if we can do something sensible with read receipts + else + if DEBUG then + perr(('Unknown event type %s%s%s in room %s%s%s'):format( + w.color'bold', + chunk.type, + default_color, + w.color'bold', + self.name, + default_color)) + dbg{chunk=chunk} + end + end +end + +function Room:Op(nick) + for id, name in pairs(self.users) do + if name == nick then + -- patch the locally cached power levels + self.power_levels.users[id] = 99 + SERVER:state(self.identifier, 'm.room.power_levels', + self.power_levels) + break + end + end +end + +function Room:Voice(nick) + for id, name in pairs(self.users) do + if name == nick then + -- patch the locally cached power levels + self.power_levels.users[id] = 25 + SERVER:state(self.identifier, 'm.room.power_levels', + self.power_levels) + break + end + end +end + +function Room:Devoice(nick) + for id, name in pairs(self.users) do + if name == nick then + -- patch the locally cached power levels + self.power_levels.users[id] = 0 + SERVER:state(self.identifier, 'm.room.power_levels', + self.power_levels) + break + end + end +end + +function Room:Deop(nick) + for id, name in pairs(self.users) do + if name == nick then + -- patch the locally cached power levels + self.power_levels.users[id] = 0 + SERVER:state(self.identifier, 'm.room.power_levels', + self.power_levels) + break + end + end +end + +function Room:Kick(nick, reason) + for id, name in pairs(self.users) do + if name == nick then + local data = { + membership = 'leave', + reason = 'Kicked by '..SERVER.user_id + } + SERVER:set_membership(self.identifier, id, data) + break + end + end +end + +function Room:Whois(nick) + for id, name in pairs(self.users) do + if name == nick then + local pcolor = wcolor'weechat.color.chat_prefix_network' + local data = ('%s--\t%s%s%s has user id %s%s'):format( + pcolor, + w.info_get('irc_nick_color', nick), + nick, + default_color, + w.info_get('irc_nick_color', id), + id) + w.print_date_tags(self.buffer, nil, 'notify_message', data) + local pdata = ('%s--\t%s%s%s has presence %s%s'):format( + pcolor, + w.info_get('irc_nick_color', nick), + nick, + default_color, + pcolor, + SERVER.presence[id] or 'offline') + w.print_date_tags(self.buffer, nil, 'notify_message', pdata) + -- TODO support printing status_msg field in presence data here + break + end + end +end + +function Room:Invite(id) + SERVER:Invite(self.identifier, id) +end + +function Room:Encrypt() + self.encrypted = true + -- Download keys for all members + self:Download_keys() + -- Create sessions + -- Pickle. + -- Save +end +function Room:Download_keys() + for id, name in pairs(self.users) do + -- TODO enable batch downloading of keys here when synapse can handle it + SERVER.olm:query({id}) + end +end + +function Room:MarkAsRead() + -- Get event id from tag of last line in buffer + local lines = w.hdata_pointer(w.hdata_get('buffer'), self.buffer, 'own_lines') + if lines == '' then return end + local line = w.hdata_pointer(w.hdata_get('lines'), lines, 'last_line') + if line == '' then return end + local hdata_line = w.hdata_get('line') + local hdata_line_data = w.hdata_get('line_data') + local data = w.hdata_pointer(hdata_line, line, 'data') + local tag_count = w.hdata_integer(hdata_line_data, data, "tags_count") + if tag_count > 0 then + for i = 0, tag_count-1 do + local tag = w.hdata_string(hdata_line_data, data, i .. "|tags_array") + -- Event ids are like $142533663810152bfUKc:matrix.org + if tag:match'^%$.*:' then + SERVER:SendReadReceipt(self.identifier, tag) + break + end + end + end +end + +function poll(a, b) + SERVER:poll() + return w.WEECHAT_RC_OK +end + +function polltimer_cb(a, b) + local now = os.time() + if (now - SERVER.polltime) > POLL_INTERVAL+10 then + -- Release the poll lock + SERVER.poll_lock = false + SERVER:poll() + end + return w.WEECHAT_RC_OK +end + +function otktimer_cb(a, b) + SERVER.olm:check_server_keycount() + return w.WEECHAT_RC_OK +end + +function cleartyping(a, b) + for id, room in pairs(SERVER.rooms) do + room:ClearTyping() + end + return w.WEECHAT_RC_OK +end + +function join_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if current_buffer == BUFFER or room then + local _, alias = split_args(args) + if not alias then + -- To support running /join on a invited room without args + SERVER:Join(room.identifier) + else + SERVER:Join(alias) + end + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function part_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + SERVER:part(room) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function leave_command_cb(data, current_buffer, args) + return part_command_cb(data, current_buffer, args) +end + +function me_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, message = split_args(args) + room:emote(message or '') + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function topic_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, topic = split_args(args) + room:Topic(topic) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function upload_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, upload = split_args(args) + room:Upload(upload) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function query_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, query = split_args(args) + for id, displayname in pairs(room.users) do + if displayname == query then + -- Create a new room and invite the guy + SERVER:CreateRoom(false, nil, {id}) + return w.WEECHAT_RC_OK_EAT + end + end + else + return w.WEECHAT_RC_OK + end +end + +function create_command_cb(data, current_buffer, args) + local command, arg = split_args(args) + local room = SERVER:findRoom(current_buffer) + if (room or current_buffer == BUFFER) and command == '/create' then + if arg then + -- Room names are supposed to be without # and homeserver, so + -- we try to help the user out here + local alias = arg:match'#?(.*):?' + -- Create a non-public room with argument as alias + SERVER:CreateRoom(false, alias, nil) + else + mprint 'Use /create room-name' + end + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function createalias_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, alias = split_args(args) + SERVER:CreateRoomAlias(room.identifier, alias) + return w.WEECHAT_RC_OK_EAT + elseif current_buffer == BUFFER then + mprint 'Use /createalias #alias:homeserver.domain from a room' + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function invite_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, invitee = split_args(args) + room:Invite(invitee) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function list_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room or current_buffer == BUFFER then + local _, target = split_args(args) + SERVER:ListRooms(target) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function op_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, target = split_args(args) + room:Op(target) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function voice_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, target = split_args(args) + room:Voice(target) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function devoice_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, target = split_args(args) + room:Devoice(target) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end +function deop_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, target = split_args(args) + room:Deop(target) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function kick_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, target = split_args(args) + room:Kick(target) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function nick_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room or current_buffer == BUFFER then + local _, nick = split_args(args) + SERVER:Nick(nick) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function whois_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, nick = split_args(args) + room:Whois(nick) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function notice_command_cb(data, current_buffer, args) + -- TODO sending from matrix buffer given a room name + local room = SERVER:findRoom(current_buffer) + if room then + local _, msg = split_args(args) + room:Notice(msg) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function msg_command_cb(data, current_buffer, args) + local _, msgmask = split_args(args) + local mask, msg = split_args(msgmask) + local room + -- WeeChat uses * as a mask for current buffer + if mask == '*' then + room = SERVER:findRoom(current_buffer) + else + for id, r in pairs(SERVER.rooms) do + -- Send /msg to a ID + if id == mask then + room = r + break + elseif mask == r.name then + room = r + break + end + end + end + + if room then + room:Msg(msg) + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function encrypt_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, arg = split_args(args) + if arg == 'on' then + mprint('Enabling encryption for outgoing messages in room ' .. tostring(room.name)) + room:Encrypt() + elseif arg == 'off' then + mprint('Disabling encryption for outgoing messages in room ' .. tostring(room.name)) + room.encrypted = false + else + w.print(current_buffer, 'Use /encrypt on or /encrypt off to turn encryption on or off') + end + return w.WEECHAT_RC_OK_EAT + else + return w.WEECHAT_RC_OK + end +end + +function public_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + mprint('Marking room as public: ' .. tostring(room.name)) + room:public() + return w.WEECHAT_RC_OK_EAT + else + mprint('Run command from a room') + return w.WEECHAT_RC_OK + end +end + +function names_command_cb(cbdata, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local nrcolor = function(nr) + return wcolor'weechat.color.chat_channel' + .. tostring(nr) + .. default_color + end + local buffer_name = nrcolor(w.buffer_get_string(room.buffer, 'name')) + local delim_c = wcolor'weechat.color.chat_delimiters' + local tags = 'no_highlight,no_log,irc_names' + local pcolor = wcolor'weechat.color.chat_prefix_network' + local ngroups = {} + local nicks = {} + for id, name in pairs(room.users) do + local ncolor + if id == SERVER.user_id then + ncolor = w.color('chat_nick_self') + else + ncolor = w.info_get('irc_nick_color', name) + end + local ngroup, nprefix, nprefix_color = room:GetNickGroup(id) + if nprefix == ' ' then nprefix = '' end + nicks[#nicks+1] = ('%s%s%s%s'):format( + w.color(nprefix_color), + nprefix, + ncolor, + name + ) + if not ngroups[ngroup] then + ngroups[ngroup] = 0 + end + ngroups[ngroup] = ngroups[ngroup] + 1 + end + local line1 = ('%s--\tNicks %s: %s[%s%s]'):format( + pcolor, + buffer_name, + delim_c, + table.concat(nicks, ' '), + delim_c + ) + w.print_date_tags(room.buffer, 0, tags, line1) + local line2 = ( + '%s--\tChannel %s: %s nicks %s(%s%s ops, %s voice, %s normals%s)' + ):format( + pcolor, + buffer_name, + nrcolor(room.member_count), + delim_c, + default_color, + nrcolor((ngroups[1] or 0) + (ngroups[2] or 0)), + nrcolor(ngroups[3] or 0), + nrcolor((ngroups[4] or 0) + (ngroups[5] or 0)), + delim_c + ) + w.print_date_tags(room.buffer, 0, tags, line2) + return w.WEECHAT_RC_OK_EAT + else + perr('Could not find room') + return w.WEECHAT_RC_OK + end +end + +function more_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + SERVER:getMessages(room.identifier, 'b', room.prev_batch, 120) + return w.WEECHAT_RC_OK_EAT + else + perr('/more Could not find room') + end + return w.WEECHAT_RC_OK +end + +function roominfo_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + dbg{room=room} + return w.WEECHAT_RC_OK_EAT + else + perr('/roominfo Could not find room') + end + return w.WEECHAT_RC_OK +end + +function name_command_cb(data, current_buffer, args) + local room = SERVER:findRoom(current_buffer) + if room then + local _, name = split_args(args) + room:Name(name) + return w.WEECHAT_RC_OK_EAT + else + perr('/name Could not find room') + end + return w.WEECHAT_RC_OK +end + +function closed_matrix_buffer_cb(data, buffer) + BUFFER = nil + return w.WEECHAT_RC_OK +end + +function closed_matrix_room_cb(data, buffer) + -- WeeChat closed our room + local room = SERVER:findRoom(buffer) + if room then + room.buffer = nil + perr('Room got closed: '..room.name) + SERVER.rooms[room.identifier] = nil + return w.WEECHAT_RC_OK + end + return w.WEECHAT_RC_ERR +end + +function typing_notification_cb(signal, sig_type, data) + -- Ignore commands + if data:match'^/' then + return w.WEECHAT_RC_OK + end + -- Is this signal coming from a matrix buffer? + local room = SERVER:findRoom(data) + if room then + local input = w.buffer_get_string(data, "input") + -- Start sending when it reaches > 4 and doesn't start with command + if #input > 4 and not input:match'^/' then + local now = os.time() + -- Generate typing events every 4th second + if SERVER.typing_time + 4 < now then + SERVER.typing_time = now + room:SendTypingNotice() + end + end + end + + return w.WEECHAT_RC_OK +end + +function buffer_switch_cb(signal, sig_type, data) + -- Update bar item + w.bar_item_update('matrix_typing_notice') + local current_buffer = w.current_buffer() + local room = SERVER:findRoom(current_buffer) + if room then + room:MarkAsRead() + end + return w.WEECHAT_RC_OK +end + +function typing_bar_item_cb(data, buffer, args) + local current_buffer = w.current_buffer() + local room = SERVER:findRoom(current_buffer) + if not room then return '' end + local typing_ids = table.concat(room.typing_ids, ' ') + if #typing_ids > 0 then + return "Typing: ".. typing_ids + end + return '' +end + +if w.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE, SCRIPT_DESC, "matrix_unload", "UTF-8") then + -- Save WeeChat version to a global so other functionality can see it + local version = w.info_get('version_number', '') or 0 + WEECHAT_VERSION = tonumber(version) + local settings = { + homeserver_url= {'https://matrix.org/', 'Full URL including port to your homeserver (including trailing slash) or use default matrix.org'}, + user= {'', 'Your homeserver username'}, + password= {'', 'Your homeserver password'}, + backlog_lines= {'120', 'Number of lines to fetch from backlog upon connecting'}, + presence_filter = {'off', 'Filter presence messages and ephemeral events (for performance)'}, + autojoin_on_invite = {'on', 'Automatically join rooms you are invited to'}, + typing_notices = {'on', 'Send typing notices when you type'}, + local_echo = {'on', 'Print lines locally instead of waiting for return from server'}, + debug = {'off', 'Print a lot of extra information to help with finding bugs and other problems.'}, + encrypted_message_color = {'lightgreen', 'Print encrypted mesages with this color'}, + --olm_secret = {'', 'Password used to secure olm stores'}, + } + -- set default settings + for option, value in pairs(settings) do + if w.config_is_set_plugin(option) ~= 1 then + w.config_set_plugin(option, value[1]) + end + if WEECHAT_VERSION >= 0x00030500 then + w.config_set_desc_plugin(option, ('%s (default: "%s")'):format( + value[2], value[1])) + end + end + errprefix = wconf'weechat.look.prefix_error' + errprefix_c = wcolor'weechat.color.chat_prefix_error' + HOMEDIR = w.info_get('weechat_dir', '') .. '/' + local commands = { + 'join', 'part', 'leave', 'me', 'topic', 'upload', 'query', 'list', + 'op', 'voice', 'deop', 'devoice', 'kick', 'create', 'createalias', 'invite', 'nick', + 'whois', 'notice', 'msg', 'encrypt', 'public', 'names', 'more', + 'roominfo', 'name' + } + for _, c in pairs(commands) do + w.hook_command_run('/'..c, c..'_command_cb', '') + end + + if w.config_get_plugin('typing_notices') == 'on' then + w.hook_signal('input_text_changed', "typing_notification_cb", '') + end + + if w.config_get_plugin('debug') == 'on' then + DEBUG = true + end + + w.hook_config('plugins.var.lua.matrix.debug', 'configuration_changed_cb', '') + + local cmds = {'help', 'connect', 'debug', 'msg'} + w.hook_command(SCRIPT_COMMAND, 'Plugin for matrix.org chat protocol', + '[command] [command options]', + 'Commands:\n' ..table.concat(cmds, '\n') .. + '\nUse /matrix help [command] to find out more\n' .. + '\nSupported slash commands (i.e. /commands):\n' .. + table.concat(commands, ', '), + -- Completions + table.concat(cmds, '|'), + 'matrix_command_cb', '') + + w.hook_command_run('/away -all*', 'matrix_away_command_run_cb', '') + SERVER = MatrixServer.create() + + if WEECHAT_VERSION < 0x01040000 then + perr(SCRIPT_NAME .. ': Please upgrade your WeeChat before using this script. Using this script on older WeeChat versions may lead to crashes. Many bugs have been fixed in newer versions of WeeChat.') + perr(SCRIPT_NAME .. ': Refusing to automatically connect you. If you insist, type /'..SCRIPT_COMMAND..' connect, and do not act surprised if it crashes :-)') + else + SERVER:connect() + end + + w.hook_signal('buffer_switch', "buffer_switch_cb", "") + w.bar_item_new('matrix_typing_notice', 'typing_bar_item_cb', '') +end |