slack_client.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803
  1. #!/usr/bin/env python3
  2. ########################################################################################
  3. # slack_client.py
  4. #
  5. # A tiny, standard-library only tool to read credentials from the Slack desktop app and
  6. # make requests to the Slack API on behalf of the user. It has access to the user's
  7. # Google Calendar via the Slack App, and the currently connected WiFi network via
  8. # `networksetup`. Requires `openssl` and `leveldbutil` to run.
  9. #
  10. # Not made to be portable.
  11. # Also a bit shit.
  12. ########################################################################################
  13. from base64 import b64encode
  14. from collections.abc import Generator
  15. from enum import StrEnum
  16. from hashlib import pbkdf2_hmac
  17. from json import loads, dumps
  18. from logging import NOTSET, basicConfig, debug, error
  19. from os import getenv, listdir, makedirs, remove, stat
  20. from os.path import join, isfile, splitext, dirname
  21. from sqlite3 import connect as connect_sqlite
  22. from string import hexdigits
  23. from subprocess import check_output, CalledProcessError, DEVNULL, STDOUT
  24. from sys import exit, argv
  25. from sys import platform
  26. from tempfile import TemporaryDirectory
  27. from time import time
  28. from typing import cast
  29. from urllib import request, parse
  30. from datetime import datetime, timedelta
  31. from http.client import HTTPResponse
  32. JSONType = str | int | float | None | list["JSONType"] | dict[str, "JSONType"]
  33. JSONDict = dict[str, JSONType]
  34. class WorkTime(StrEnum):
  35. WEEKEND = "WEEKEND"
  36. OFF_HOURS = "OFF_HOURS"
  37. WORK_MORNING = "WORK_MORNING"
  38. WORK_AFTERNOON = "WORK_AFTERNOON"
  39. class EnvConfigKey(StrEnum):
  40. XDG_CACHE_HOME = "XDG_CACHE_HOME"
  41. SLACK_CLIENT_CALENDAR_EVENT_NAME_BREAK = "SLACK_CLIENT_CALENDAR_EVENT_NAME_BREAK"
  42. SLACK_CLIENT_CALENDAR_EVENT_NAME_HOME = "SLACK_CLIENT_CALENDAR_EVENT_NAME_HOME"
  43. SLACK_CLIENT_CALENDAR_EVENT_NAME_OFFICE = "SLACK_CLIENT_CALENDAR_EVENT_NAME_OFFICE"
  44. SLACK_CLIENT_CALENDAR_EVENT_NAME_OUT = "SLACK_CLIENT_CALENDAR_EVENT_NAME_OUT"
  45. SLACK_CLIENT_CALENDAR_EVENT_NAME_SICK = "SLACK_CLIENT_CALENDAR_EVENT_NAME_SICK"
  46. SLACK_CLIENT_NETWORK_NAME_HOME = "SLACK_CLIENT_NETWORK_NAME_HOME"
  47. SLACK_CLIENT_NETWORK_NAME_OFFICE = "SLACK_CLIENT_NETWORK_NAME_OFFICE"
  48. SLACK_CLIENT_NETWORK_NAME_TRANSIT = "SLACK_CLIENT_NETWORK_NAME_TRANSIT"
  49. SLACK_CLIENT_STATUS_HOME = "SLACK_CLIENT_STATUS_HOME"
  50. SLACK_CLIENT_STATUS_HOME_LATER_OFFICE = "SLACK_CLIENT_STATUS_HOME_LATER_OFFICE"
  51. SLACK_CLIENT_STATUS_HOME_MEETING = "SLACK_CLIENT_STATUS_HOME_MEETING"
  52. SLACK_CLIENT_STATUS_OFFICE = "SLACK_CLIENT_STATUS_OFFICE"
  53. SLACK_CLIENT_STATUS_OFFICE_MEETING = "SLACK_CLIENT_STATUS_OFFICE_MEETING"
  54. SLACK_CLIENT_STATUS_OFF_HOURS = "SLACK_CLIENT_STATUS_OFF_HOURS"
  55. SLACK_CLIENT_STATUS_OUT = "SLACK_CLIENT_STATUS_OUT"
  56. SLACK_CLIENT_STATUS_SICK = "SLACK_CLIENT_STATUS_SICK"
  57. SLACK_CLIENT_STATUS_TRANSIT_FROM_OFFICE = "SLACK_CLIENT_STATUS_TRANSIT_FROM_OFFICE"
  58. SLACK_CLIENT_STATUS_TRANSIT_TO_OFFICE = "SLACK_CLIENT_STATUS_TRANSIT_TO_OFFICE"
  59. SLACK_CLIENT_STATUS_VACATION = "SLACK_CLIENT_STATUS_VACATION"
  60. SLACK_CLIENT_STATUS_WEEKEND = "SLACK_CLIENT_STATUS_WEEKEND"
  61. class NetworkLocation(StrEnum):
  62. HOME = "HOME"
  63. OFFICE = "OFFICE"
  64. TRANSIT = "TRANSIT"
  65. UNKNOWN = "UNKNOWN"
  66. class CalendarWorkLocation(StrEnum):
  67. HOME = "HOME"
  68. OFFICE = "OFFICE"
  69. TRANSIT = "TRANSIT"
  70. UNKNOWN = "UNKNOWN"
  71. OUT = "OUT"
  72. SICK = "SICK"
  73. VACATION = "VACATION"
  74. class UserState(StrEnum):
  75. OFF = "OFF"
  76. WEEKEND = "WEEKEND"
  77. OUT = "OUT"
  78. SICK = "SICK"
  79. VACATION = "VACATION"
  80. UNKNOWN = "UNKNOWN"
  81. HOME = "HOME"
  82. HOME_LATER_OFFICE = "HOME_LATER_OFFICE"
  83. HOME_MEETING = "HOME_MEETING"
  84. OFFICE = "OFFICE"
  85. OFFICE_MEETING = "OFFICE_MEETING"
  86. TRANSIT_FROM_OFFICE = "TRANSIT_FROM_OFFICE"
  87. TRANSIT_FROM_OFFICE_MEETING = "TRANSIT_FROM_OFFICE_MEETING"
  88. TRANSIT_TO_OFFICE = "TRANSIT_TO_OFFICE"
  89. TRANSIT_TO_OFFICE_MEETING = "TRANSIT_TO_OFFICE_MEETING"
  90. class Cacheable:
  91. _ensured = False
  92. _cache_path: str = "cache.json"
  93. _cache_expiry = 60 * 60 * 24
  94. def _ensure_and_clear_cache(self):
  95. if self._ensured:
  96. return
  97. makedirs(dirname(self._cache_path), exist_ok=True)
  98. if not isfile(self._cache_path):
  99. with open(self._cache_path, "w") as f:
  100. _ = f.write(dumps({}))
  101. self._ensured = True
  102. if (
  103. datetime.now().timestamp() - stat(self._cache_path).st_birthtime
  104. > self._cache_expiry
  105. ):
  106. debug(f"Reset cache {self._cache_path}")
  107. remove(self._cache_path)
  108. with open(self._cache_path, "w") as f:
  109. _ = f.write(dumps({}))
  110. def _read_cache(self, key: str) -> JSONType:
  111. self._ensure_and_clear_cache()
  112. with open(self._cache_path, "r") as f:
  113. cache: JSONDict = loads(f.read())
  114. if key in cache:
  115. debug(f"{self.__class__.__name__} read cached value `{key}`")
  116. return cache[key]
  117. def _write_cache(self, key: str, value: JSONType):
  118. self._ensure_and_clear_cache()
  119. with open(self._cache_path, "r") as f:
  120. cache: JSONDict = loads(f.read())
  121. cache[key] = value
  122. with open(self._cache_path, "w") as f:
  123. _ = f.write(dumps(cache))
  124. class TimeParser:
  125. def __init__(self, date: datetime) -> None:
  126. self._date = date
  127. def read(
  128. self,
  129. ) -> tuple[WorkTime, int, int, int, int]:
  130. return (
  131. self._get_worktime(),
  132. self._get_workday_end_timestamp(),
  133. self._get_workday_start_timestamp(),
  134. self._get_future_round_timestamp(30),
  135. self._get_future_round_timestamp(60),
  136. )
  137. def _get_worktime(self) -> WorkTime:
  138. weekday = int(self._date.strftime("%w"))
  139. is_weekday = bool(weekday % 6)
  140. hour = int(self._date.strftime("%H"))
  141. if not is_weekday or (weekday == 5 and hour >= 16):
  142. return WorkTime.WEEKEND
  143. if hour < 8 or hour >= 18:
  144. return WorkTime.OFF_HOURS
  145. elif hour <= 12:
  146. return WorkTime.WORK_MORNING
  147. else:
  148. return WorkTime.WORK_AFTERNOON
  149. def _get_workday_end_timestamp(self) -> int:
  150. return round(
  151. datetime(
  152. year=self._date.year,
  153. month=self._date.month,
  154. day=self._date.day,
  155. hour=17,
  156. ).timestamp()
  157. )
  158. def _get_workday_start_timestamp(self) -> int:
  159. future = datetime(
  160. year=self._date.year,
  161. month=self._date.month,
  162. day=self._date.day,
  163. hour=8,
  164. minute=0,
  165. )
  166. if self._date.hour > 17:
  167. future += timedelta(days=1)
  168. while future.weekday() >= 5:
  169. future += timedelta(days=1)
  170. return round(future.timestamp())
  171. def _get_future_round_timestamp(self, minutes: int) -> int:
  172. future = self._date + timedelta(minutes=minutes)
  173. if future.minute % 15 == 0:
  174. future += timedelta(minutes=1)
  175. while future.minute % 15 != 0:
  176. future += timedelta(minutes=1)
  177. return round(future.timestamp())
  178. class EnvConfigParser:
  179. def __init__(self):
  180. self._app_config: dict[EnvConfigKey, str] = {}
  181. for key in EnvConfigKey:
  182. value = getenv(key)
  183. if value is None:
  184. raise ValueError(f"Undefined environment variable: {key}")
  185. self._app_config[key] = value
  186. def read(self):
  187. return self._app_config
  188. def __getitem__(self, name: EnvConfigKey) -> str:
  189. return self._app_config[name]
  190. class NetworkParser:
  191. def __init__(self, env_config: EnvConfigParser):
  192. self._config = env_config.read()
  193. def read(self) -> NetworkLocation:
  194. connected = next(
  195. i.replace(" ", "").replace("\t", "")
  196. for i in (
  197. check_output(["networksetup", "-listpreferredwirelessnetworks", "en0"])
  198. .decode("utf8")
  199. .split("\n")
  200. )[1:]
  201. )
  202. if connected == self._config[EnvConfigKey.SLACK_CLIENT_NETWORK_NAME_HOME]:
  203. return NetworkLocation.HOME
  204. if connected == self._config[EnvConfigKey.SLACK_CLIENT_NETWORK_NAME_OFFICE]:
  205. return NetworkLocation.OFFICE
  206. if connected == self._config[EnvConfigKey.SLACK_CLIENT_NETWORK_NAME_TRANSIT]:
  207. return NetworkLocation.TRANSIT
  208. return NetworkLocation.UNKNOWN
  209. class CookiesParser(Cacheable):
  210. # Read and decrypt Chrome cookies from SQLite database
  211. # Useful resources:
  212. # * https://gist.github.com/creachadair/937179894a24571ce9860e2475a2d2ec
  213. # * https://n8henrie.com/2014/05/decrypt-chrome-cookies-with-python/
  214. def __init__(self, env_config: EnvConfigParser, path: str):
  215. self._path = join(path, "Cookies")
  216. self._env_config = env_config
  217. self._key = None
  218. self._cookies: list[tuple[str, str]] | None = None
  219. self._cache_path = join(
  220. self._env_config[EnvConfigKey.XDG_CACHE_HOME],
  221. "slack_client",
  222. "cookies_cache.json",
  223. )
  224. self._cache_expiry = 60 * 60 * 24 * 365
  225. def read(self) -> Generator[tuple[str, str]]:
  226. if self._key is None:
  227. self._key = self._make_key()
  228. self._cookies = []
  229. for key, value in self._iterate_values():
  230. value = value[3:]
  231. decoded = self._decode(self._key, value)
  232. self._cookies.append((key, decoded))
  233. yield self._cookies[-1]
  234. def _get_password(self, cache: bool = False) -> str:
  235. if cache:
  236. return str(self._read_cache("password"))
  237. try:
  238. security_result = check_output(
  239. ["security", "find-generic-password", "-g", "-s", "Slack Safe Storage"],
  240. stderr=STDOUT,
  241. )
  242. except CalledProcessError:
  243. return self._get_password(True)
  244. password = next(
  245. l
  246. for l in security_result.decode("utf8").split("\n")
  247. if l.startswith("password:")
  248. )
  249. password = password[11:-1]
  250. self._write_cache("password", password)
  251. return password
  252. def _make_key(
  253. self,
  254. ):
  255. return pbkdf2_hmac(
  256. "sha1", self._get_password().encode("utf8"), b"saltysalt", 1003
  257. )[:16]
  258. def _decode(self, key: bytes, value: bytes, cache: bool = True) -> str:
  259. cache_key = b64encode(key + value).decode("ascii")
  260. if (
  261. cache
  262. and (cached_value := self._read_cache(cache_key))
  263. and type(cached_value) == str
  264. ):
  265. return cached_value
  266. with TemporaryDirectory() as directory:
  267. keyfile: str = join(directory, "keyfile")
  268. infile: str = join(directory, "infile")
  269. outfile: str = join(directory, "outfile")
  270. with open(keyfile, "wb") as f:
  271. _ = f.write(key)
  272. with open(infile, "wb") as f:
  273. _ = f.write(value)
  274. _ = check_output(
  275. [
  276. "openssl",
  277. "enc",
  278. "-d",
  279. "-aes-128-cbc",
  280. "-K",
  281. "".join([hex(i)[2:] for i in key]),
  282. "-in",
  283. infile,
  284. "-out",
  285. outfile,
  286. "-iv",
  287. "".join([hex(i)[2:] for i in b" " * 16]),
  288. "-p",
  289. ]
  290. )
  291. with open(outfile, "rb") as f:
  292. result = f.read()
  293. result = result.decode("utf8")
  294. self._write_cache(cache_key, result)
  295. return result
  296. def _iterate_values(self) -> list[tuple[str, bytes]]:
  297. con = connect_sqlite(self._path)
  298. cur = con.cursor()
  299. res = cur.execute(
  300. 'SELECT name, encrypted_value FROM cookies WHERE host_key=".slack.com"'
  301. )
  302. all: list[tuple[str, bytes]] = res.fetchall()
  303. return list([r for r in all])
  304. class LevelDBParser:
  305. def __init__(self, path: str):
  306. self._path = join(path, "Local Storage/leveldb/")
  307. self._app_configs = None
  308. def read(self):
  309. if self._app_configs is None:
  310. self._app_configs = [c for c in self._load_configs()]
  311. return [c for c in self._app_configs]
  312. def _read_files(self):
  313. for file in listdir(self._path):
  314. file = join(self._path, file)
  315. if not isfile(file) or splitext(file)[-1] != ".ldb":
  316. continue
  317. content = (
  318. check_output(["leveldbutil", "dump", file]).decode("utf8").split("\n")
  319. )
  320. for line in content:
  321. if len(line) == 0:
  322. continue
  323. if line[0] != "'":
  324. debug("Invalid start character detected in " + file)
  325. continue
  326. yield line
  327. def _get_keys_and_values(self):
  328. for line in self._read_files():
  329. key_end = line.find("'", 1)
  330. key = line[1 : key_end - 1]
  331. line = line[key_end:]
  332. line = line[line.find("@") :]
  333. line = line[line.find(":") :]
  334. line = line[line.find("'") :]
  335. if line[0] != "'" or line[-1] != "'":
  336. debug("Invalid start or end character detected")
  337. continue
  338. value = line[1:-1]
  339. yield key, value
  340. def _get_json_values(self):
  341. for key, value in self._get_keys_and_values():
  342. while (
  343. len(value) >= 4
  344. and value[0] == "\\"
  345. and value[1] == "x"
  346. and value[2] in hexdigits
  347. and value[3] in hexdigits
  348. ):
  349. value = value[4:]
  350. try:
  351. value: JSONType = loads(value)
  352. except:
  353. continue
  354. if type(value) != dict:
  355. continue
  356. yield key, value
  357. def _load_configs(self):
  358. def find_config(data: JSONDict) -> JSONDict | None:
  359. if (
  360. "token" in data
  361. and type(data["token"]) == str
  362. and "domain" in data
  363. and "url" in data
  364. and type(data["url"]) == str
  365. and "id" in data
  366. and data["url"].endswith(".slack.com/")
  367. and data["token"].startswith("xoxc")
  368. ):
  369. return data
  370. for _, value in data.items():
  371. if type(value) == dict:
  372. return find_config(value)
  373. for _, value in self._get_json_values():
  374. config = find_config(value)
  375. if config:
  376. yield config
  377. class CalendarParser:
  378. def __init__(self, blocks: list[list[str]], env_config: EnvConfigParser):
  379. self._blocks = blocks
  380. self._env_config = env_config
  381. def read(
  382. self,
  383. ) -> tuple[
  384. CalendarWorkLocation, bool, tuple[list[tuple[list[int], str]], list[list[str]]]
  385. ]:
  386. location: CalendarWorkLocation = CalendarWorkLocation.UNKNOWN
  387. sick = False
  388. out = False
  389. vacation = False
  390. meeting = False
  391. current: list[list[str]] = []
  392. items: list[tuple[list[int], str]] = []
  393. for block in self._blocks:
  394. if block[-1] == "All-day":
  395. if (
  396. "|"
  397. + self._env_config[
  398. EnvConfigKey.SLACK_CLIENT_CALENDAR_EVENT_NAME_OFFICE
  399. ]
  400. in block[0]
  401. ):
  402. location = CalendarWorkLocation.OFFICE
  403. elif (
  404. "|"
  405. + self._env_config[
  406. EnvConfigKey.SLACK_CLIENT_CALENDAR_EVENT_NAME_HOME
  407. ]
  408. in block[0]
  409. ):
  410. location = CalendarWorkLocation.HOME
  411. elif (
  412. "|"
  413. + self._env_config[
  414. EnvConfigKey.SLACK_CLIENT_CALENDAR_EVENT_NAME_BREAK
  415. ]
  416. in block[0]
  417. ):
  418. vacation = True
  419. elif (
  420. "|"
  421. + self._env_config[
  422. EnvConfigKey.SLACK_CLIENT_CALENDAR_EVENT_NAME_SICK
  423. ]
  424. in block[0]
  425. ):
  426. sick = True
  427. elif (
  428. "|"
  429. + self._env_config[
  430. EnvConfigKey.SLACK_CLIENT_CALENDAR_EVENT_NAME_OUT
  431. ]
  432. in block[0]
  433. ):
  434. out = True
  435. current.append(block)
  436. elif "!date^" in block[-1]:
  437. chunks = block[-1].split("!date^")
  438. times: list[int] = []
  439. for chunk in chunks:
  440. subchunks = chunk.split("^")
  441. try:
  442. times.append(int(subchunks[0]))
  443. except:
  444. pass
  445. if len(times) != 2:
  446. continue
  447. items.append((times, block[0]))
  448. if times[0] < time() and times[1] > time():
  449. meeting = True
  450. current.append(block)
  451. if out:
  452. location = CalendarWorkLocation.OUT
  453. if sick:
  454. location = CalendarWorkLocation.SICK
  455. if vacation:
  456. location = CalendarWorkLocation.VACATION
  457. return location, meeting, (items, current)
  458. class SlackClient(Cacheable):
  459. def __init__(
  460. self,
  461. env_config: EnvConfigParser,
  462. leveldb_parser: LevelDBParser,
  463. cookies: CookiesParser,
  464. ):
  465. self._env_config = env_config
  466. self._cookies = list(cookies.read())
  467. # TODO: Add support for multiple Slack workspaces by selecting config by ID
  468. self._app_config = leveldb_parser.read().pop()
  469. self._cache_path = join(
  470. self._env_config[EnvConfigKey.XDG_CACHE_HOME],
  471. "slack_client",
  472. "slack_cache.json",
  473. )
  474. self._cache_expiry = 60 * 60
  475. self._token = (
  476. self._app_config["token"]
  477. if "token" in self._app_config and type(self._app_config["token"]) == str
  478. else ""
  479. )
  480. def _call(
  481. self,
  482. method: str,
  483. form_data: None | JSONDict = None,
  484. cache: bool = False,
  485. ) -> JSONDict:
  486. cache_key = dumps([method, form_data])
  487. if cache and (cached := self._read_cache(cache_key)) and type(cached) == dict:
  488. return cached
  489. req = request.Request(
  490. f'{self._app_config["url"]}/api/{method}',
  491. headers={
  492. "Authorization": "Bearer " + self._token,
  493. "Cookie": ";".join([f"{k}={v}" for k, v in self._cookies]),
  494. },
  495. data=parse.urlencode(
  496. (form_data if form_data else {})
  497. | {"_x_mode": "online", "_x_sonic": "true", "_x_app_name": "client"}
  498. ).encode(),
  499. )
  500. response = cast(HTTPResponse, request.urlopen(req))
  501. result: JSONDict = loads(response.read())
  502. if result["ok"]:
  503. if cache:
  504. self._write_cache(cache_key, result)
  505. return result
  506. raise ValueError("Bad result from Slack API call:", dumps(result))
  507. def update_status(self, text: str, emoji: str, expiration: int):
  508. return self._call(
  509. "users.profile.set",
  510. {
  511. "profile": {
  512. "status_emoji": emoji,
  513. "status_expiration": expiration,
  514. "status_text": text,
  515. "status_text_canonical": "",
  516. "ooo_message": "",
  517. },
  518. "_x_reason": "CustomStatusModal:handle_save",
  519. },
  520. )
  521. def update_presence(self, presence: bool):
  522. return self._call("profile.set", {"presence": "active" if presence else "away"})
  523. def get_calendar(self):
  524. users = self._call("users.list", cache=True)
  525. calendar_user = next(
  526. i
  527. for i in cast(list[JSONType], users["members"])
  528. if type(i) == dict
  529. and i["is_bot"]
  530. and "real_name" in i
  531. and i["real_name"] == "Google Calendar"
  532. )
  533. bot_convo = self._call(
  534. "conversations.open", {"users": calendar_user["id"]}, cache=True
  535. )
  536. convo_id: str = cast(str, cast(JSONDict, bot_convo["channel"])["id"])
  537. convo_info = self._call(
  538. "conversations.info", {"channel": convo_id, "return_app_home": True}, False
  539. )
  540. app_view = self._call(
  541. "views.get",
  542. {"view_id": cast(JSONDict, convo_info["home_view"])["id"]},
  543. False,
  544. )
  545. texts = [
  546. cast(str, cast(JSONDict, b["text"])["text"]).split("\n")
  547. for b in cast(list[JSONDict], cast(JSONDict, app_view["view"])["blocks"])
  548. if "text" in b
  549. ]
  550. texts = [t for t in texts if len(t) and any(len(b) for b in t) > 0]
  551. texts = [loads(t) for t in set(dumps(t) for t in texts)]
  552. return texts
  553. def userstate_parse(
  554. worktime: WorkTime,
  555. network_location: NetworkLocation,
  556. work_location: CalendarWorkLocation,
  557. meeting: bool,
  558. ) -> UserState:
  559. if network_location == NetworkLocation.OFFICE:
  560. if meeting and (worktime == "afternoon" or worktime == "evening"):
  561. return UserState.OFFICE_MEETING
  562. return UserState.OFFICE
  563. elif work_location == CalendarWorkLocation.VACATION:
  564. return UserState.VACATION
  565. elif work_location == CalendarWorkLocation.SICK:
  566. return UserState.SICK
  567. elif work_location == CalendarWorkLocation.OUT:
  568. return UserState.OUT
  569. elif worktime == WorkTime.WEEKEND:
  570. return UserState.WEEKEND
  571. elif worktime == WorkTime.OFF_HOURS:
  572. return UserState.OFF
  573. elif work_location == CalendarWorkLocation.OFFICE:
  574. if network_location == NetworkLocation.TRANSIT:
  575. if worktime == WorkTime.WORK_MORNING:
  576. if meeting:
  577. return UserState.TRANSIT_TO_OFFICE_MEETING
  578. return UserState.TRANSIT_TO_OFFICE
  579. elif worktime == WorkTime.WORK_AFTERNOON:
  580. if meeting:
  581. return UserState.TRANSIT_FROM_OFFICE_MEETING
  582. return UserState.TRANSIT_FROM_OFFICE
  583. elif network_location == NetworkLocation.HOME:
  584. if worktime == WorkTime.WORK_MORNING:
  585. return UserState.HOME_LATER_OFFICE
  586. elif worktime == WorkTime.WORK_AFTERNOON:
  587. return userstate_parse(
  588. worktime, network_location, CalendarWorkLocation.HOME, meeting
  589. )
  590. else:
  591. return userstate_parse(
  592. worktime, NetworkLocation.OFFICE, work_location, meeting
  593. )
  594. elif work_location == CalendarWorkLocation.HOME:
  595. if meeting:
  596. return UserState.HOME_MEETING
  597. return UserState.HOME
  598. elif work_location == CalendarWorkLocation.UNKNOWN:
  599. return userstate_parse(
  600. worktime, network_location, CalendarWorkLocation.HOME, meeting
  601. )
  602. return UserState.UNKNOWN
  603. def envvar_to_slack_status(status: str) -> tuple[str, str]:
  604. if "|" not in status:
  605. raise ValueError(status)
  606. emoji, text = tuple(status.split("|"))[:2]
  607. return (":" + emoji + ":") if len(emoji) > 0 else emoji, text
  608. def main():
  609. base_path = join(getenv("HOME", "~"), "Library/Application Support/Slack")
  610. env_config_parser = EnvConfigParser()
  611. leveldb_parser = LevelDBParser(base_path)
  612. cookies_parser = CookiesParser(env_config_parser, base_path)
  613. network_parser = NetworkParser(env_config_parser)
  614. time_parser = TimeParser(datetime.now())
  615. client = SlackClient(env_config_parser, leveldb_parser, cookies_parser)
  616. calendar_parser = CalendarParser(client.get_calendar(), env_config_parser)
  617. worktime, workday_end_expiry, workday_start_expiry, halfhour_expiry, hour_expiry = (
  618. time_parser.read()
  619. )
  620. work_location, meeting, (calendar_items, _) = calendar_parser.read()
  621. for calendar_item in calendar_items:
  622. debug(f"Calendar item: {calendar_item}")
  623. network_location = network_parser.read()
  624. userstate = userstate_parse(worktime, network_location, work_location, meeting)
  625. debug(
  626. f"State information: \
  627. {userstate=} \
  628. {worktime=}, \
  629. {network_location=}, \
  630. {work_location=}, \
  631. {meeting=}"
  632. )
  633. status_emoji: None | str = None
  634. status_text: None | str = None
  635. status_expiration: None | int = None
  636. match userstate:
  637. case UserState.OFF:
  638. status_emoji, status_text = envvar_to_slack_status(
  639. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_OFF_HOURS]
  640. )
  641. status_expiration = workday_start_expiry
  642. case UserState.WEEKEND:
  643. status_emoji, status_text = envvar_to_slack_status(
  644. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_WEEKEND]
  645. )
  646. status_expiration = workday_start_expiry
  647. case UserState.OUT:
  648. status_emoji, status_text = envvar_to_slack_status(
  649. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_OUT]
  650. )
  651. case UserState.SICK:
  652. status_emoji, status_text = envvar_to_slack_status(
  653. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_SICK]
  654. )
  655. status_expiration = workday_end_expiry
  656. case UserState.VACATION:
  657. status_emoji, status_text = envvar_to_slack_status(
  658. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_VACATION]
  659. )
  660. case UserState.HOME:
  661. status_emoji, status_text = envvar_to_slack_status(
  662. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_HOME]
  663. )
  664. status_expiration = workday_end_expiry
  665. case UserState.HOME_LATER_OFFICE:
  666. status_emoji, status_text = envvar_to_slack_status(
  667. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_HOME_LATER_OFFICE]
  668. )
  669. status_expiration = hour_expiry
  670. case UserState.HOME_MEETING:
  671. status_emoji, status_text = envvar_to_slack_status(
  672. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_HOME_MEETING]
  673. )
  674. status_expiration = halfhour_expiry
  675. case UserState.OFFICE:
  676. status_emoji, status_text = envvar_to_slack_status(
  677. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_OFFICE]
  678. )
  679. status_expiration = workday_end_expiry
  680. case UserState.OFFICE_MEETING:
  681. status_emoji, status_text = envvar_to_slack_status(
  682. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_OFFICE_MEETING]
  683. )
  684. status_expiration = halfhour_expiry
  685. case UserState.TRANSIT_FROM_OFFICE:
  686. status_emoji, status_text = envvar_to_slack_status(
  687. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_TRANSIT_FROM_OFFICE]
  688. )
  689. status_expiration = halfhour_expiry
  690. case UserState.TRANSIT_FROM_OFFICE_MEETING:
  691. status_emoji, status_text = envvar_to_slack_status(
  692. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_TRANSIT_FROM_OFFICE]
  693. )
  694. status_expiration = halfhour_expiry
  695. case UserState.TRANSIT_TO_OFFICE:
  696. status_emoji, status_text = envvar_to_slack_status(
  697. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_TRANSIT_TO_OFFICE]
  698. )
  699. status_expiration = halfhour_expiry
  700. case UserState.TRANSIT_TO_OFFICE_MEETING:
  701. status_emoji, status_text = envvar_to_slack_status(
  702. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_TRANSIT_TO_OFFICE]
  703. )
  704. status_expiration = halfhour_expiry
  705. case UserState.UNKNOWN:
  706. pass
  707. _ = client.update_status(
  708. status_text if status_text else "",
  709. status_emoji if status_emoji else "",
  710. status_expiration if status_expiration else 0,
  711. )
  712. debug(f"Updated status: {status_text=} {status_emoji=} {status_expiration=}")
  713. if __name__ == "__main__":
  714. basicConfig(level=NOTSET)
  715. debug(f"Called at {datetime.now().ctime()} with parameters {argv}")
  716. if platform != "darwin":
  717. error(f"This client only runs on macOS")
  718. exit(1)
  719. for program in ["networksetup", "leveldbutil", "openssl"]:
  720. try:
  721. _ = check_output([program], stderr=DEVNULL)
  722. except CalledProcessError:
  723. pass
  724. except:
  725. error(f"Aborted, as `{program}` doesn't appear to be callable")
  726. exit(1)
  727. main()