diff --git a/.gitignore b/.gitignore index 2b3a801..83161e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ *.pyc .idea .vagrant - +.venv/ +.claude/ +slack_asterisk.egg-info/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8a86fbc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /usr/src/app + +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt +RUN mkdir /data +COPY slack_asterisk /usr/src/app/slack_asterisk + +ENV LISTEN_IP=127.0.0.1 +ENV LISTEN_PORT=4574 +ENV LOG_LEVEL=INFO + +EXPOSE 4574 + +CMD exec python -m slack_asterisk.main -i "$LISTEN_IP" -p "$LISTEN_PORT" diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..3f1b9d2 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,24 @@ +@Library("jenkins-pipelines") _ + +pipeline { + agent { + label 'docker-jenkins' + } + + stages { + stage('Checkout SCM') { + steps { + script { + checkout scm + } + } + } + stage('Build') { + steps { + script { + dockerBuild.buildAndPush(["linux/amd64", "linux/arm64"]) + } + } + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d83fb2 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# slack_asterisk + +This is a Asterisk Slack Integration, which provides logging of incoming and outgoing phone calls including call progress. +It will post a initial message on the first call and updates this on call progress. +It is implemented as a Asterisk FastAGI server (listening on local socket port 4574). + +![Slack Asterisk example](slack_asterisk_example.png?raw=true "Example Output") + +## Requirements + + - Python 3.12+ + - configobj, validate + - slack_sdk + - Flask + +For a Python 2.7 version see release tag 0.10. + +## Installation + +Use: + +`python3 setup.py install` + +or build a rpm using the provided RPM spec file (tested using CentOS 7). +Also a SystemD Unit file is provided, which allows starting and stopping of the FastAGI service. + +## Configuration + +You will need a Slack API Token to be able to post messages to Slack. Create a Slack API Token with the proper +permissions and put the token into a environment variable, before starting the daemon. In addition you can use +a config file in /etc/slack-asterisk.conf to override the default values. + +Start using Slack Token from environment: + +``` +export SLACK_TOKEN=xoxb-... +# Optional: control verbosity (DEBUG, INFO, WARNING, ERROR) +export LOG_LEVEL=INFO +slack-asterisk +``` + +The provided SystemD Unit file will read the Slack Token from /etc/slack-asterisk.token. + +### Asterisk extensions + +Once the service is running, you need to have the FastAGI included in your extensions. Be sure to included +all incoming and outgoing dials and all call states. + +``` +; incoming call extension +same => n,AGI(agi://127.0.0.1:4574/) +same => n,Dial(SIP/FIXME,120,trM(slack-answered^${UNIQUEID})) ; Macro see below + +... +; congestion, hangup, ... call states +exten => congestion,1,Noop(Congestion called) +same => next,AGI(agi://127.0.0.1:4574/) +same => next,Goto(noanswer,1) + +exten => chanunavail,1,Noop(Channel unavailable called) +same => next,AGI(agi://127.0.0.1:4574/) +same => next,Goto(noanswer,1) + +exten => h,1,Noop(Channel hangup called) +same => next,AGI(agi://127.0.0.1:4574/) + +exten => noanswer,1,Set(GREETING=u) +same => next,Goto(leave-voicemail,${EXTEN},1) + +exten => busy,1,Set(GREETING=b) +same => next,AGI(agi://127.0.0.1:4574/) +same => next,Goto(leave-voicemail,${EXTEN},1) +``` + +In additon define the Macro given below, to catch answered calls. + +``` +[macro-slack-answered] +exten => s,1,Noop(macro slack-answered called) +same => next,AGI(agi://127.0.0.1:4574/) +``` + +### Config file + +Config file options are the following (defaults are given): + +``` +[general] +ip = 127.0.0.1 +port = 4574 + +[slack] +client_id = "" +client_secret = "" +channel = "telefon" + +username = "User" +emoji = ":telephone_receiver:" +``` + +## Notes on recent changes + +- The HTTP component now uses Flask instead of Falcon. +- Slack client migrated from `slackclient` to `slack_sdk`. +- The FastAGI server is implemented using Python's `socketserver` with threading. +- Logging verbosity is configurable via the `LOG_LEVEL` environment variable. +- Project is updated and tested for Python 3.12. diff --git a/Vagrantfile b/Vagrantfile deleted file mode 120000 index 28ff5d2..0000000 --- a/Vagrantfile +++ /dev/null @@ -1 +0,0 @@ -../vagrant-config/Vagrantfile \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6cfaf68 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +--index-url https://pypi.python.org/simple/ + +configobj==5.0.9 +Flask==3.1.3 +slack_sdk==3.33.4 diff --git a/setup.py b/setup.py index 647b7c5..2bd9228 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- from setuptools import setup, find_packages @@ -6,15 +6,15 @@ import slack_asterisk setup( - name='slack-asterisk', - version=slack_asterisk.__version__, - description='Cygnus Networks GmbH Slack Asterisk App', - author='Torge Szczepanek', - author_email='debian@cygnusnetworks.de', - maintainer='Torge Szczepanek', - maintainer_email='debian@cygnusnetworks.de', - license='FIXME', - entry_points={'console_scripts': ['slack-asterisk = slack_asterisk.main:main']}, - packages=find_packages(), - install_requires=['configobj', 'pyst', 'slackclient', 'falcon'] + name='slack-asterisk', + version=slack_asterisk.__version__, + description='Slack Asterisk App', + author='cygnusb', + author_email='cygnusb@users.noreply.github.com', + maintainer='Torge Szczepanek', + maintainer_email='cygnusb', + license='Apache-2.0', + entry_points={'console_scripts': ['slack-asterisk = slack_asterisk.main:main']}, + packages=find_packages(), + install_requires=['configobj', 'validate', 'Flask', 'slack_sdk'] ) diff --git a/slack-asterisk.spec b/slack-asterisk.spec deleted file mode 100644 index e54f551..0000000 --- a/slack-asterisk.spec +++ /dev/null @@ -1,112 +0,0 @@ -%if 0%{?rhel} && 0%{?rhel} <= 7 -%{!?py2_build: %global py2_build %{__python2} setup.py build} -%{!?py2_install: %global py2_install %{__python2} setup.py install --skip-build --root %{buildroot}} -%endif - -%if (0%{?fedora} >= 21 || 0%{?rhel} >= 8) -%global with_python3 1 -%endif - -%define srcname slack_asterisk -%define version 0.10 -%define release 1 -%define sum Slack Asterisk Integration - -Name: python-%{srcname} -Version: %{version} -Release: %{release}%{?dist} -Summary: %{sum} -License: proprietary -Source0: python-%{srcname}-%{version}.tar.gz - -BuildArch: noarch -BuildRequires: python2-devel, python-setuptools, python2-mock -%{?systemd_requires} -BuildRequires: systemd -%if 0%{?with_check} -BuildRequires: pytest -%endif # with_check -Requires: python-setuptools, python-configobj, python-vobject, python-pyst - -%{?python_provide:%python_provide python-%{project}} - -%if 0%{?with_python3} -BuildRequires: python3-devel -BuildRequires: python3-setuptools -%if 0%{?with_check} -BuildRequires: python3-pytest -%endif # with_check -%endif # with_python3 - -%description -%{sum} - -%if 0%{?with_python3} -%package -n python3-%{project} -Summary: %{sum} -%{?python_provide:%python_provide python3-%{project}} -Requires: python3-setuptools - -%description -n python3-%{project} -%{sum} -%endif # with_python3 - -%prep -%setup -q -n python-%{srcname}-%{version} - -%build -%py2_build - -%if 0%{?with_python3} -%py3_build -%endif # with_python3 - - -%install -%py2_install -mkdir -p $RPM_BUILD_ROOT%{_unitdir} -install -p -m 644 ./slack-asterisk.service $RPM_BUILD_ROOT%{_unitdir}/slack-asterisk.service -%if 0%{?with_python3} -%py3_install -mkdir -p $RPM_BUILD_ROOT%{_unitdir} -%endif # with_python3 - -%if 0%{?with_check} -%check -LANG=en_US.utf8 py.test-%{python2_version} -vv tests - -%if 0%{?with_python3} -LANG=en_US.utf8 py.test-%{python3_version} -vv tests -%endif # with_python3 -%endif # with_check - -%post -%systemd_post slack-asterisk.service - -%preun -%systemd_preun slack-asterisk.service - -%postun -%systemd_postun_with_restart slack-asterisk.service - -%files -%dir %{python2_sitelib}/%{srcname} -%{python2_sitelib}/%{srcname}/*.* -%{python2_sitelib}/%{srcname}-%{version}-py2.*.egg-info -%{_unitdir}/slack-asterisk.service -%{_bindir}/slack-asterisk - -%if 0%{?with_python3} -%files -n python3-%{project} -%dir %{python3_sitelib}/%{srcname} -%dir %{python3_sitelib}/%{srcname}/__pycache__ -%{python3_sitelib}/%{srcname}/*.* -%{python3_sitelib}/%{srcname}/__pycache__/*.py* -%{python3_sitelib}/%{srcname}-%{version}-py3.*.egg-info -%{_unitdir}/slack-asterisk.service -%{_bindir}/slack-asterisk -%endif # with_python3 - -%changelog -* Thu Feb 11 2016 Dr. Torge Szczepanek -- Fix source name (t.szczepanek@cygnusnetworks.de) diff --git a/slack_asterisk/__init__.py b/slack_asterisk/__init__.py index 0fe1fb6..2c2f46b 100644 --- a/slack_asterisk/__init__.py +++ b/slack_asterisk/__init__.py @@ -1,2 +1,2 @@ # coding=utf-8 -__version__ = "0.10" +__version__ = "0.20" diff --git a/slack_asterisk/agi_server.py b/slack_asterisk/agi_server.py index 0487816..539b9e2 100644 --- a/slack_asterisk/agi_server.py +++ b/slack_asterisk/agi_server.py @@ -1,248 +1,402 @@ # coding=utf-8 -import SocketServer +import socketserver import datetime import logging import os import sys +import threading -import asterisk.agi -import slackclient +from slack_sdk import WebClient log = logging.getLogger("slack_asterisk") +LOG_SPEC = "%(name)s[%(process)s]: %(filename)s:%(lineno)d/%(funcName)s###%(message)s" -class SlackAsterisk(SocketServer.StreamRequestHandler, SocketServer.ThreadingMixIn, object): - @staticmethod - def get_vars(agi): - chan_vars = dict() - chan_vars["callerid_num"] = agi.get_variable('CALLERID(num)') - chan_vars["callerid_name"] = agi.get_variable('CALLERID(name)') - chan_vars["uniqueid"] = agi.get_variable('UNIQUEID') - chan_vars["arg1"] = agi.get_variable('ARG1') - chan_vars["refid"] = agi.get_variable('SLACK_ASTERISK_REFID') - - chan_vars["dialstatus"] = agi.get_variable('DIALSTATUS') - chan_vars["dialedpeername"] = agi.get_variable('DIALEDPEERNAME') - chan_vars["dialedpeernumber"] = agi.get_variable('DIALEDPEERNUMBER') - chan_vars["dialedtime"] = agi.get_variable("DIALEDTIME") - chan_vars["answeredtime"] = agi.get_variable("ANSWEREDTIME") - chan_vars["exten"] = agi.get_variable('EXTEN') - chan_vars["hangupcause"] = agi.get_variable('HANGUPCAUSE') - - chan_vars["type"] = agi.get_variable('SLACK_ASTERISK_TYPE') - chan_vars["color"] = agi.get_variable('SLACK_ASTERISK_COLOR') - - chan_vars["direction"] = agi.get_variable('SLACK_ASTERISK_DIRECTION') - - to_delete = [] - for k, v in chan_vars.items(): - if len(v) == 0: - to_delete.append(k) - for entry in to_delete: - del chan_vars[entry] - return chan_vars - - def get_formatting(self, msg, msg_data, color="good"): - actions = None - if msg_data["direction"] == "out": - title = "➡️ " - else: - title = "⬅️ " - title += "Call from " - title += msg_data["from_num"] - if msg_data["from_name"] is not None and msg_data["from_name"] is not "anonymous": - title += " (%s) " % msg_data["from_name"] - - footer = "Time: %s" % msg_data["ts_in"].strftime("%A %d.%m.%Y %H:%M:%S") - if msg_data["dialedtime"] is not None: - footer += " - Dialed for %s" % str(datetime.timedelta(seconds=msg_data["dialedtime"])) - if msg_data["answeredtime"] is not None: - footer += " - Answered for %s" % str(datetime.timedelta(seconds=msg_data["answeredtime"])) - if msg_data["color"] is not None: - color = msg_data["color"] - if msg_data["type"] is not None: - msg = "%s: %s" % (msg_data["type"], msg) - data = dict(color=color, title=title, text=msg, username=self.server.config["username"], icon_emoji=self.server.config["emoji"], actions=actions, footer=footer) - return data - - def update_message(self, msg, msg_data, color="good"): - data = self.get_formatting(msg, msg_data, color) - att = [data] - log.debug("Channel update called for channel %s", self.server.config["channel"]) - method = "chat.update" - ret = self.server.slack_client.api_call(method, channel=msg_data["channel"], attachments=att, ts=msg_data["ts"]) - if ret["ok"] is not True: - raise RuntimeError("Cannot post message with error %s" % ret["error"]) - - def post_message(self, msg, msg_data, color="good"): - data = self.get_formatting(msg, msg_data, color) - att = [data] - log.debug("Channel post called for channel %s", self.server.config["channel"]) - method = "chat.postMessage" - ret = self.server.slack_client.api_call(method, channel=self.server.config["channel"], attachments=att) - - if ret["ok"] is not True: - raise RuntimeError("Cannot post message with error %s" % ret["error"]) - else: - return ret["ts"], ret["channel"] - - @staticmethod - def get_destination(msg_data): - log.debug("get_destination called with msg_data %s", msg_data) - dest = "Unknown" - if msg_data["to_num"] is not None: - dest = msg_data["to_num"] - if msg_data["to_name"] is not None: - dest = " (%s)" % msg_data["to_name"] - log.debug("Destination results in %s", dest) - return dest - - @staticmethod - def get_dialedpeernumber(dp): - num = None - try: - num = dp.split("/")[1] - except Exception as e: - log.debug("Error in parsing dialedpeernumber by / with msg %s", e) - try: - num = dp.split("@")[0] - except Exception as e: - log.debug("Error in parsing dialedpeernumber by @ with msg %s", e) - log.debug("Dialed peer number %s leads to number %s", dp, num) - return num - - def handle(self): # pylint:disable=too-many-statements - log.debug("Received FastAGI request for client %s:%s", self.client_address[0], self.client_address[1]) - try: - devnull = open(os.devnull, 'w') - agi = asterisk.agi.AGI(self.rfile, self.wfile, devnull) - channel_vars = self.get_vars(agi) - log.debug("FastAGI request for client %s:%s for %s", self.client_address[0], self.client_address[1], str(channel_vars)) - - new_call = False - if "arg1" in channel_vars: - # case Dial Macro, call completed - msg_data = self.server.calls_dict[channel_vars["arg1"]] - else: - # all other cases - if channel_vars["uniqueid"] not in self.server.calls_dict: - self.server.calls_dict[channel_vars["uniqueid"]] = dict(ts=None, channel=None, from_num=None, from_name=None, to_num=None, to_name=None, ts_in=datetime.datetime.now(), ts_connected=None, dialedtime=None, answeredtime=None, color=None, type=None, direction=None) - msg_data = self.server.calls_dict[channel_vars["uniqueid"]] - if msg_data["from_num"] is None: - msg_data["from_num"] = channel_vars["callerid_num"] - if msg_data["direction"] is None and "direction" in channel_vars: - msg_data["direction"] = channel_vars["direction"] - else: - msg_data["direction"] = "in" - if "callerid_name" in channel_vars: - if channel_vars["callerid_name"] != channel_vars["callerid_num"]: - msg_data["from_name"] = channel_vars["callerid_name"] - else: - msg_data["from_name"] = "anonymous" - if msg_data["to_num"] is None: - msg_data["to_num"] = channel_vars["exten"] - agi.set_variable("SLACK_ASTERISK_REFID", channel_vars["uniqueid"]) - new_call = True - else: - msg_data = self.server.calls_dict[channel_vars["uniqueid"]] - - if "color" in channel_vars: - msg_data["color"] = channel_vars["color"] - if "type" in channel_vars: - msg_data["type"] = channel_vars["type"] - - if "dialedtime" in channel_vars: - msg_data["dialedtime"] = int(channel_vars["dialedtime"]) - if "answeredtime" in channel_vars: - msg_data["answeredtime"] = int(channel_vars["answeredtime"]) - - if new_call is True: - # this is a new detected call which is not in a macro - log.debug("New call detected for uniqueid %s", channel_vars["uniqueid"]) - if msg_data["direction"] == "in": - text = "📞 Incoming call (ringing)" - else: - text = "📞 Outgoing call (ringing) to %s" % channel_vars["exten"] - (ts, channel) = self.post_message(text, msg_data) - msg_data["ts"] = ts - msg_data["channel"] = channel - elif "arg1" in channel_vars: - log.debug("Picked up call detected for uniqueid %s", channel_vars["uniqueid"]) - # this is a picked up call in a dial M macro - if "dialedpeernumber" in channel_vars: - log.debug("Found dp number in channel vars %s", channel_vars) - msg_data["to_num"] = self.get_dialedpeernumber(channel_vars["dialedpeernumber"]) - else: - log.debug("No dialed peer number in channel vars %s", channel_vars) - if "callerid_num" in channel_vars: - msg_data["to_num"] = channel_vars["callerid_num"] - if "callerid_name" in channel_vars: - msg_data["to_name"] = channel_vars["callerid_name"] - dest = self.get_destination(msg_data) - self.update_message("☑️ Call established with %s" % dest, msg_data) - elif "dialstatus" in channel_vars: - log.debug("finished call detected for uniqueid %s", channel_vars["uniqueid"]) - dest = self.get_destination(msg_data) - # this is a finished call - if channel_vars["dialstatus"] == "ANSWER": - text = "✅ Call ended" - color = "good" - elif channel_vars["dialstatus"] == "BUSY": - text = "⭕ Busy" - color = "warning" - elif channel_vars["dialstatus"] == "NOANSWER": - text = "❌️ Not answered" - color = "warning" - elif channel_vars["dialstatus"] == "CANCEL": - text = "❌ Canceled" - color = "warning" - elif channel_vars["dialstatus"] == "CONGESTION": - text = "❌ Congestion" - color = "#9400D3" - elif channel_vars["dialstatus"] == "CHANUNAVAIL": - text = "❌ Channel unavailable" - color = "#9400D3" - elif channel_vars["dialstatus"] == "DONTCALL": - text = "❌ Reject (don't call)" - color = "#A9A9A9" - elif channel_vars["dialstatus"] == "TORTURE": - text = "❌ Reject (torture)" - color = "#A9A9A9" - else: - text = "Unknown" - if msg_data["direction"] == "in": - text += " from %s" % dest - else: - text += " to %s" % dest - self.update_message(text, msg_data, color=color) - elif "hangupcause" in channel_vars and int(channel_vars["hangupcause"]) > 0: - dest = self.get_destination(msg_data) - self.update_message("Call hung up by %s" % dest, msg_data) - else: - self.update_message("Unknown call state", msg_data) - except Exception as e: - del agi - log.exception("Exception occured with mesage %s", e) - finally: - devnull.close() +def _configure_logging_from_env(): + level_name = os.getenv("LOG_LEVEL", os.getenv("DEBUG_LEVEL", "INFO")).upper() + level = getattr(logging, level_name, logging.INFO) + fmt = logging.Formatter("%(asctime)s.%(msecs)06d" + " " + LOG_SPEC, datefmt='%Y-%m-%dT%T') + if not log.handlers: + h = logging.StreamHandler(sys.stdout) + h.setFormatter(fmt) + h.setLevel(level) + log.addHandler(h) + log.setLevel(level) + + +_configure_logging_from_env() + + +class SlackAsterisk(socketserver.StreamRequestHandler): + + # --- Minimal built-in AGI helpers (replacing asterisk_agi) --- + def _readline(self): + """Read a single line from the socket, return str without CRLF or None.""" + data = b"" + try: + while not data.endswith(b"\n"): + chunk = self.request.recv(1) + if not chunk: + break + data += chunk + except Exception as e: # pragma: no cover + log.debug("Error while reading line from socket: %s", e) + if not data: + return None + return data.decode(errors="ignore").rstrip("\r\n") + + def _read_agi_env(self): + """Read AGI environment lines until a blank line, return dict.""" + env = {} + while True: + line = self._readline() + if line is None: + break + if line == "": + break + parts = line.split(":", 1) + if len(parts) == 2: + env[parts[0].strip()] = parts[1].strip() + log.debug("AGI env: %s", env) + return env + + def _send_cmd(self, cmd: str) -> str: + """Send a raw AGI command and return the single-line reply.""" + log.debug("AGI >> %s", cmd) + try: + self.request.sendall((cmd + "\n").encode()) + except Exception as e: # pragma: no cover + log.debug("Error sending AGI command: %s", e) + reply = self._readline() or "" + log.debug("AGI << %s", reply) + return reply + + def _set_var(self, name: str, value: str | None) -> None: + if value is None: + value = "" + safe = value.replace('"', '\\"') + self._send_cmd(f'SET VARIABLE {name} "{safe}"') + + def get_variable(self, name: str) -> str | None: + """Get an Asterisk variable value via AGI. + + Parses typical responses like: + 200 result=1 (foo) + 200 result=0 + Returns the string value or None if not set. + """ + reply = self._send_cmd(f"GET VARIABLE {name}") + # Quick parse + # Find result= + result_pos = reply.find("result=") + if result_pos == -1: + return None + try: + after = reply[result_pos + len("result="):] + # result may be like 1 (value) or 0 + # extract integer result + res_str = "" + i = 0 + while i < len(after) and after[i].isdigit(): + res_str += after[i] + i += 1 + res = int(res_str) if res_str else 0 + if res == 0: + return None + # try to extract value inside parentheses + lpar = reply.find("(") + rpar = reply.rfind(")") + if lpar != -1 and rpar != -1 and rpar > lpar: + val = reply[lpar + 1:rpar] + # Some asterisk versions wrap empty value as ""; normalize + if val == '""': + return "" + return val + # sometimes value is quoted without parentheses in some impls + return "" + except Exception: # pragma: no cover + return None + + def get_vars(self): + chan_vars = dict() + chan_vars["callerid_num"] = self.get_variable('CALLERID(num)') + chan_vars["callerid_name"] = self.get_variable('CALLERID(name)') + chan_vars["uniqueid"] = self.get_variable('UNIQUEID') + chan_vars["arg1"] = self.get_variable('ARG1') + chan_vars["refid"] = self.get_variable('SLACK_ASTERISK_REFID') + chan_vars["info_text"] = self.get_variable('SLACK_ASTERISK_INFO_TEXT') + chan_vars["title_text"] = self.get_variable('SLACK_ASTERISK_TITLE_TEXT') + + chan_vars["dialstatus"] = self.get_variable('DIALSTATUS') + chan_vars["dialedpeername"] = self.get_variable('DIALEDPEERNAME') + chan_vars["dialedpeernumber"] = self.get_variable('DIALEDPEERNUMBER') + chan_vars["dialedtime"] = self.get_variable("DIALEDTIME") + chan_vars["answeredtime"] = self.get_variable("ANSWEREDTIME") + chan_vars["exten"] = self.get_variable('EXTEN') + chan_vars["hangupcause"] = self.get_variable('HANGUPCAUSE') + + chan_vars["type"] = self.get_variable('SLACK_ASTERISK_TYPE') + chan_vars["color"] = self.get_variable('SLACK_ASTERISK_COLOR') + + chan_vars["direction"] = self.get_variable('SLACK_ASTERISK_DIRECTION') + + to_delete = [] + for k, v in chan_vars.items(): + if not v: + to_delete.append(k) + for entry in to_delete: + del chan_vars[entry] + return chan_vars + + def get_formatting(self, msg, msg_data, color="good"): + actions = None + if msg_data["direction"] == "out": + title = "➡️ " + else: + title = "⬅️ " + title += "Call from " + title += msg_data["from_num"] + if msg_data["from_name"] and msg_data["from_name"] != "anonymous": + title += " (%s) " % msg_data["from_name"] + + if msg_data["title_text"] is not None: + title += " - " + msg_data["title_text"] + + footer = "Time: %s" % msg_data["ts_in"].strftime("%A %d.%m.%Y %H:%M:%S") + if msg_data["dialedtime"] is not None: + footer += " - Dialed for %s" % str(datetime.timedelta(seconds=msg_data["dialedtime"])) + if msg_data["answeredtime"] is not None: + footer += " - Answered for %s" % str(datetime.timedelta(seconds=msg_data["answeredtime"])) + if msg_data["color"] is not None: + color = msg_data["color"] + if msg_data["type"] is not None: + msg = "%s: %s" % (msg_data["type"], msg) + + data = dict(color=color, title=title, text=msg, fallback=title[2:], username=self.server.config["username"], icon_emoji=self.server.config["emoji"], actions=actions, footer=footer) + return data + + def update_message(self, msg, msg_data, color="good"): + data = self.get_formatting(msg, msg_data, color) + att = [data] + log.debug("Channel update called for channel #%s with attachment %s", self.server.config["channel"], att) + ret = self.server.slack_client.chat_update(channel=msg_data["channel"], attachments=att, ts=msg_data["ts"]) + if ret["ok"] is not True: + raise RuntimeError("Cannot post message with error %s" % ret["error"]) + + def post_message(self, msg, msg_data, color="good"): + data = self.get_formatting(msg, msg_data, color) + att = [data] + log.debug("Channel post called for channel #%s with attachment %s", self.server.config["channel"], att) + ret = self.server.slack_client.chat_postMessage(channel=self.server.config["channel"], attachments=att) + + if ret["ok"] is not True: + raise RuntimeError("Cannot post message with error %s" % ret["error"]) + return ret["ts"], ret["channel"] + + @staticmethod + def get_destination(msg_data): + log.debug("get_destination called with msg_data %s", msg_data) + dest = "Unknown" + if msg_data["to_num"] is not None: + dest = msg_data["to_num"] + if msg_data["to_name"] is not None: + dest = " (%s)" % msg_data["to_name"] + log.debug("Destination results in %s", dest) + return dest + + @staticmethod + def get_dialedpeernumber(dp): + num = None + try: + num = dp.split("/")[1] + except Exception as e: + log.debug("Error in parsing dialedpeernumber by / with msg %s", e) + try: + num = dp.split("@")[0] + except Exception as e: + log.debug("Error in parsing dialedpeernumber by @ with msg %s", e) + log.debug("Dialed peer number %s leads to number %s", dp, num) + return num + + def handle(self): # pylint:disable=too-many-statements + log.debug("Received FastAGI request for client %s:%s", self.client_address[0], self.client_address[1]) + try: # pylint:disable=too-many-nested-blocks + # Read and log AGI environment + _env = self._read_agi_env() + # Collect channel vars using minimal GET VARIABLE calls + channel_vars = self.get_vars() + log.debug("FastAGI channel vars from %s:%s -> %s", self.client_address[0], self.client_address[1], channel_vars) + + new_call = False + call_key = None + # Lock calls_dict for the check-and-create section to prevent race conditions + # between concurrent threads serving different AGI requests. + with self.server.calls_lock: + if "arg1" in channel_vars: + # case Dial Macro, call completed + call_key = channel_vars["arg1"] + msg_data = self.server.calls_dict.get(call_key) + if msg_data is None: + log.warning("ARG1 references unknown call ID %s – ignoring request", call_key) + return + else: + # all other cases + call_key = channel_vars.get("uniqueid") + if not call_key: + log.warning("No uniqueid in channel vars (broken connection?) – ignoring request") + return + if call_key not in self.server.calls_dict: + self.server.calls_dict[call_key] = dict(ts=None, channel=None, from_num=None, from_name=None, to_num=None, to_name=None, ts_in=datetime.datetime.now(), ts_connected=None, dialedtime=None, answeredtime=None, title_text=None, info_text=None, color=None, type=None, direction=None) + new_call = True + msg_data = self.server.calls_dict[call_key] + + if new_call: + if msg_data["from_num"] is None: + msg_data["from_num"] = channel_vars["callerid_num"] + if msg_data["direction"] is None and "direction" in channel_vars: + msg_data["direction"] = channel_vars["direction"] + else: + msg_data["direction"] = "in" + if "callerid_name" in channel_vars: + if channel_vars["callerid_name"] != channel_vars["callerid_num"]: + msg_data["from_name"] = channel_vars["callerid_name"] + else: + msg_data["from_name"] = "anonymous" + if msg_data["to_num"] is None: + msg_data["to_num"] = channel_vars["exten"] + self._set_var("SLACK_ASTERISK_REFID", channel_vars["uniqueid"]) + + if "info_text" in channel_vars: + msg_data["info_text"] = channel_vars["info_text"] + if "title_text" in channel_vars: + msg_data["title_text"] = channel_vars["title_text"] + if "color" in channel_vars: + msg_data["color"] = channel_vars["color"] + if "type" in channel_vars: + msg_data["type"] = channel_vars["type"] + + if "dialedtime" in channel_vars: + msg_data["dialedtime"] = int(channel_vars["dialedtime"]) + if "answeredtime" in channel_vars: + msg_data["answeredtime"] = int(channel_vars["answeredtime"]) + + if new_call is True: + # this is a new detected call which is not in a macro + log.debug("New call detected for uniqueid %s", channel_vars["uniqueid"]) + if msg_data["direction"] == "in": + text = "📞 Incoming call (ringing)" + else: + text = "📞 Outgoing call (ringing) to %s" % channel_vars["exten"] + if msg_data["info_text"] is not None: + text += " (%s)" % msg_data["info_text"] + (ts, channel) = self.post_message(text, msg_data) + msg_data["ts"] = ts + msg_data["channel"] = channel + elif "arg1" in channel_vars: + log.debug("Picked up call detected for uniqueid %s", channel_vars["uniqueid"]) + # this is a picked up call in a dial M macro + if "dialedpeernumber" in channel_vars: + log.debug("Found dp number in channel vars %s", channel_vars) + msg_data["to_num"] = self.get_dialedpeernumber(channel_vars["dialedpeernumber"]) + else: + log.debug("No dialed peer number in channel vars %s", channel_vars) + if "callerid_num" in channel_vars: + msg_data["to_num"] = channel_vars["callerid_num"] + if "callerid_name" in channel_vars: + msg_data["to_name"] = channel_vars["callerid_name"] + dest = self.get_destination(msg_data) + self.update_message("☑️ Call established with %s" % dest, msg_data) + elif "dialstatus" in channel_vars: + log.debug("finished call detected for uniqueid %s", channel_vars["uniqueid"]) + dest = self.get_destination(msg_data) + # set color to grey as default + color = "#333333" + # this is a finished call + if channel_vars["dialstatus"] == "ANSWER": + text = "✅ Call ended" + color = "good" + elif channel_vars["dialstatus"] == "BUSY": + text = "⭕ Busy" + color = "warning" + elif channel_vars["dialstatus"] == "NOANSWER": + text = "❌️ Not answered" + color = "warning" + elif channel_vars["dialstatus"] == "CANCEL": + text = "❌ Canceled" + color = "warning" + elif channel_vars["dialstatus"] == "CONGESTION": + text = "❌ Congestion" + color = "#9400D3" + elif channel_vars["dialstatus"] == "CHANUNAVAIL": + text = "❌ Channel unavailable" + color = "#9400D3" + elif channel_vars["dialstatus"] == "DONTCALL": + text = "❌ Reject (don't call)" + color = "#A9A9A9" + elif channel_vars["dialstatus"] == "TORTURE": + text = "❌ Reject (torture)" + color = "#A9A9A9" + else: + text = "Unknown" + if msg_data["direction"] == "in": + text += " from %s" % dest + else: + text += " to %s" % dest + self.update_message(text, msg_data, color=color) + # Call is finished – remove from memory to prevent unbounded growth + with self.server.calls_lock: + self.server.calls_dict.pop(call_key, None) + log.debug("Removed finished call %s (calls_dict size: %d)", call_key, len(self.server.calls_dict)) + elif "hangupcause" in channel_vars and int(channel_vars["hangupcause"]) > 0: + dest = self.get_destination(msg_data) + self.update_message("Call hung up by %s" % dest, msg_data) + # Call is finished – remove from memory to prevent unbounded growth + with self.server.calls_lock: + self.server.calls_dict.pop(call_key, None) + log.debug("Removed hung-up call %s (calls_dict size: %d)", call_key, len(self.server.calls_dict)) + elif "hangupcause" in channel_vars and int(channel_vars["hangupcause"]) <= 0: + self.update_message("Unknown call state (hangupcause %i)" % int(channel_vars["hangupcause"]), msg_data) + else: + self.update_message("Unknown call state", msg_data) + + except Exception as e: + log.exception("Exception occurred with message %s", e) + + +class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True): # noqa: N803 (keep arg names per base class) + # Predefine instance attributes to satisfy linters and clarify intent. + self.slack_client = None # type: ignore[assignment] + self.config = {} # type: ignore[assignment] + self.calls_dict = {} + self.calls_lock = threading.Lock() + super().__init__(server_address, RequestHandlerClass, bind_and_activate) def agi_server(ip, port, config): - slack_token = os.environ["SLACK_TOKEN"] - sc = slackclient.SlackClient(slack_token) - - SocketServer.TCPServer.allow_reuse_address = True - server = SocketServer.TCPServer((ip, port), SlackAsterisk) - server.slack_client = sc - server.config = config["slack"] - server.calls_dict = dict() - - try: - log.debug("Server FastAGI on %s:%s", ip, port) - server.serve_forever() - except KeyboardInterrupt as e: - log.info("Shutdown on ctrl-c") - sys.exit(0) - except Exception as e: - log.exception("Unknown Exception %s occured", e) - sys.exit(1) + # SECURITY NOTE: The AGI TCP server has no built-in authentication. + # Any process that can reach ip:port can trigger Slack messages. + # The default bind address is 127.0.0.1 (localhost-only), which is + # sufficient for local Asterisk deployments. Do NOT expose this port + # to untrusted networks without adding a firewall rule or network ACL. + slack_token = os.environ["SLACK_TOKEN"] + sc = WebClient(slack_token) + + ThreadedTCPServer.allow_reuse_address = True + server = ThreadedTCPServer((ip, port), SlackAsterisk) + server.slack_client = sc + server.config = config["slack"] + server.calls_dict = dict() + + try: + log.debug("Server FastAGI on %s:%s", ip, port) + server.serve_forever() + except KeyboardInterrupt as e: # pylint:disable=unused-variable + log.info("Shutdown on ctrl-c") + sys.exit(0) + except Exception as e: + log.exception("Unknown Exception %s occurred", e) + sys.exit(1) diff --git a/slack_asterisk/config.py b/slack_asterisk/config.py index 3a0188b..c8a31ae 100644 --- a/slack_asterisk/config.py +++ b/slack_asterisk/config.py @@ -17,6 +17,11 @@ class SlackAsteriskConfig(object): # pylint:disable=too-few-public-methods + # SECURITY NOTE: The config file at /etc/slack-asterisk.conf may contain + # sensitive credentials (client_id, client_secret). Ensure the file is + # readable only by the service user: + # chown root: /etc/slack-asterisk.conf + # chmod 640 /etc/slack-asterisk.conf def __init__(self, configfile="/etc/slack-asterisk.conf"): config_spec_parsed = configobj.ConfigObj(CONFIG_SPEC_SOURCE.format().splitlines(), list_values=False) diff --git a/slack_asterisk/exceptions.py b/slack_asterisk/exceptions.py new file mode 100644 index 0000000..da04275 --- /dev/null +++ b/slack_asterisk/exceptions.py @@ -0,0 +1,57 @@ +# coding=utf-8 +# pylint: disable=unnecessary-pass + + +class AGIException(Exception): + """Base AGI Exception""" + pass + + +class AGIError(AGIException): + """AGI Generic Error""" + pass + + +class AGIUnknownError(AGIError): + """AGI Unknown Error""" + pass + + +class AGIAppError(AGIError): + """AGI App Error""" + pass + + +class AGIHangup(AGIAppError): + """Base AGI Hangup""" + pass + + +class AGISIGHUPHangup(AGIHangup): + """AGI SIGHUP Hangup""" + pass + + +class AGISIGPIPEHangup(AGIHangup): + """AGI SIGPIPE Hangup""" + pass + + +class AGIResultHangup(AGIHangup): + """AGI Result Hangup""" + pass + + +class AGIDBError(AGIAppError): + """AGI Database Error""" + pass + + +class AGIUsageError(AGIError): + """AGI Usage Error""" + pass + + +class AGIInvalidCommand(AGIError): + """AGI Invalid Command Error""" + pass diff --git a/slack_asterisk/http_server.py b/slack_asterisk/http_server.py index cc9532b..0e94054 100644 --- a/slack_asterisk/http_server.py +++ b/slack_asterisk/http_server.py @@ -1,40 +1,43 @@ +"""Simple Flask-based HTTP server (replacing Falcon). + +Exposes a single health endpoint returning "OK". Intended as a lightweight +status/oAuth callback placeholder matching previous behavior. +""" + # coding=utf-8 -import falcon import logging -import wsgiref -import wsgiref.simple_server -import SocketServer +import os +from flask import Flask, request, Response log = logging.getLogger("slack_asterisk") -app = falcon.API() - -class ThreadingWSGIServer(SocketServer.ThreadingMixIn, wsgiref.simple_server.WSGIServer): # pylint:disable=too-few-public-methods - pass +def _init_logging_from_env(): + level_name = os.getenv("LOG_LEVEL", os.getenv("DEBUG_LEVEL", "INFO")).upper() + level = getattr(logging, level_name, logging.INFO) + if not log.handlers: + ch = logging.StreamHandler() + ch.setLevel(level) + log.addHandler(ch) + log.setLevel(level) -class NoLoggingWSGIRequestHandler(wsgiref.simple_server.WSGIRequestHandler, object): # pylint:disable=too-few-public-methods - """WSGIRequestHandler that logs to debug instead of stderr""" +_init_logging_from_env() - def log_message(self, _, *args): - # pylint:disable=W1401 - """Log an arbitrary message to log.debug - """ - # pylint:enable=W1401 - log.debug("HTTP server request %s - status %s - length %s", *args) +app = Flask(__name__) -class HTTPHandler(object): # pylint:disable=too-few-public-methods - def on_get(self, req, resp): # pylint:disable=no-self-use - log.debug("Got GET request for %s", req.path) - resp.status = falcon.HTTP_200 - resp.body = "OK" +@app.route("/", methods=["GET"]) # health/simple endpoint +def root_ok(): # pylint:disable=unused-variable + log.debug("Got %s request for %s", request.method, request.path) + return Response("OK", status=200, mimetype="text/plain") def oauth_server(ip, port, _): - httph = HTTPHandler() - app.add_route('/', httph) - http_serv = wsgiref.simple_server.make_server(ip, port, app, server_class=ThreadingWSGIServer, handler_class=NoLoggingWSGIRequestHandler) - http_serv.serve_forever() + # SECURITY NOTE: Flask's built-in development server is used here. + # It is not suitable for production: it has no request concurrency limits, + # no TLS support, and limited error isolation. For production deployments, + # replace this with a WSGI server such as Gunicorn or uWSGI, e.g.: + # gunicorn -w 2 -b ip:port "slack_asterisk.http_server:app" + app.run(host=ip, port=port, threaded=True) diff --git a/slack_asterisk/main.py b/slack_asterisk/main.py index c18269f..34ced48 100644 --- a/slack_asterisk/main.py +++ b/slack_asterisk/main.py @@ -2,39 +2,61 @@ import argparse import logging import sys +import signal +import os from . import __version__ from . import agi_server from . import config log = logging.getLogger("slack_asterisk") -log.setLevel(logging.DEBUG) -ch = logging.StreamHandler(sys.stdout) -log.addHandler(ch) + + +def _init_logging_from_env(): + level_name = os.getenv("LOG_LEVEL", os.getenv("DEBUG_LEVEL", "INFO")).upper() + level = getattr(logging, level_name, logging.INFO) + if not log.handlers: + ch = logging.StreamHandler(sys.stdout) + ch.setLevel(level) + log.addHandler(ch) + log.setLevel(level) + + +_init_logging_from_env() + + +def _handle_stop_signal(signum, frame): # pylint:disable=unused-argument + log.info("Received signal %s, stopping AGI server immediately", signum) + # Immediate stop – terminate the process + sys.exit(0) def main(): - argp = argparse.ArgumentParser() - argp.add_argument("-i", "--ip", help="Set bind ip", default=None) - argp.add_argument("-p", "--port", help="Set bind port", type=int, default=None) + # Ensure SLACK_TOKEN is set in environment + if not os.environ.get("SLACK_TOKEN"): + log.error("Environment variable SLACK_TOKEN must be set before starting slack_asterisk.") + sys.exit(1) + + conf = config.SlackAsteriskConfig() + c = conf.get_configobj() + + argp = argparse.ArgumentParser() + argp.add_argument("-i", "--ip", help="Set bind ip", default=c["general"]["ip"]) + argp.add_argument("-p", "--port", help="Set bind port", type=int, default=c["general"]["port"]) + + args = argp.parse_args() # pylint: disable=W0612 - args = argp.parse_args() # pylint: disable=W0612 + ip = args.ip + port = args.port - conf = config.SlackAsteriskConfig() - c = conf.get_configobj() + # Register signal handlers so external stops (SIGTERM/SIGINT) kill the server immediately + signal.signal(signal.SIGTERM, _handle_stop_signal) + signal.signal(signal.SIGINT, _handle_stop_signal) - if args.ip is not None: - ip = args.ip - else: - ip = c["general"]["ip"] + log.info("slack_asterisk version %s starting AGI server on %s:%i", __version__, ip, port) - log.info("slack_asterisk version %s starting AGI server", __version__) - if args.port is not None: - port = args.port - else: - port = c["general"]["port"] - agi_server.agi_server(ip, port, c) + agi_server.agi_server(ip, port, c) if __name__ == "__main__": - main() + main() diff --git a/slack_asterisk_example.png b/slack_asterisk_example.png new file mode 100644 index 0000000..7969e19 Binary files /dev/null and b/slack_asterisk_example.png differ diff --git a/vagrant.yaml b/vagrant.yaml deleted file mode 100644 index 5683d01..0000000 --- a/vagrant.yaml +++ /dev/null @@ -1,4 +0,0 @@ ---- -vm: - box: 'geerlingguy/centos7' -sync: default