dash-ai-chatbot 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asyntai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ include LICENSE
2
+ include README.md
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: dash-ai-chatbot
3
+ Version: 1.0.0
4
+ Summary: Adds the Asyntai AI chat assistant to a Plotly Dash app. It answers visitor questions from your own content, in more than 80 languages.
5
+ Author-email: Asyntai <hello@asyntai.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://asyntai.com
8
+ Project-URL: Documentation, https://asyntai.com/documentation/integrations/plotly-dash/
9
+ Project-URL: Source, https://github.com/asyntai/dash-ai-chatbot
10
+ Project-URL: Issues, https://github.com/asyntai/dash-ai-chatbot/issues
11
+ Keywords: dash,plotly,plotly-dash,chatbot,ai,ai-chatbot,live-chat,customer-support,assistant,widget
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: Dash
15
+ Classifier: Framework :: Flask
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: dash>=2.0
28
+ Dynamic: license-file
29
+
30
+ # dash-ai-chatbot
31
+
32
+ Adds the [Asyntai](https://asyntai.com) AI chat assistant to a
33
+ [Plotly Dash](https://dash.plotly.com) app. The assistant answers visitor
34
+ questions from your own content, day and night, in more than 80 languages.
35
+
36
+ Free and open source. Works on every Asyntai plan, including the free plan.
37
+
38
+ - Requires Dash 2.0 or newer and Python 3.10 or newer
39
+ - Documentation: https://asyntai.com/documentation/integrations/plotly-dash/
40
+
41
+ ## Install
42
+
43
+ ```
44
+ pip install dash-ai-chatbot
45
+ ```
46
+
47
+ Add one line after you create the app:
48
+
49
+ ```python
50
+ from dash import Dash, html
51
+ import dash_ai_chatbot
52
+
53
+ app = Dash(__name__)
54
+ app.layout = html.Div("Hello")
55
+
56
+ dash_ai_chatbot.init_app(app, widget_id="asyntai_ab12cd34")
57
+
58
+ if __name__ == "__main__":
59
+ app.run()
60
+ ```
61
+
62
+ You get the ID from the Install page of your
63
+ [Asyntai dashboard](https://asyntai.com/dashboard). You can paste the whole
64
+ snippet; the package keeps only the ID.
65
+
66
+ That is all. The chat button appears on every page of the app, including
67
+ multi-page apps.
68
+
69
+ ## No code change (Dash 3 and later)
70
+
71
+ On Dash 3 and later the package also works with no code change. Install it and
72
+ set one environment variable:
73
+
74
+ ```
75
+ ASYNTAI_WIDGET_ID=asyntai_ab12cd34
76
+ ```
77
+
78
+ Every Dash app that runs with that variable gets the chat. Without the
79
+ variable the package does nothing.
80
+
81
+ ## Keep the ID out of the code
82
+
83
+ Leave out `widget_id` and the package reads `ASYNTAI_WIDGET_ID` each time the
84
+ page is built:
85
+
86
+ ```python
87
+ dash_ai_chatbot.init_app(app)
88
+ ```
89
+
90
+ ## Options
91
+
92
+ ```python
93
+ dash_ai_chatbot.init_app(
94
+ app,
95
+ widget_id="asyntai_ab12cd34", # or the whole snippet; empty or invalid = chat off
96
+ script_url=None, # change only if Asyntai support asks you to
97
+ )
98
+ ```
99
+
100
+ `script_url` can also come from the `ASYNTAI_SCRIPT_URL` environment variable.
101
+ Only an `https` address is accepted.
102
+
103
+ ## How it works
104
+
105
+ Dash serves one HTML page for the whole app. The package puts a small script
106
+ just before the closing body tag of that page. The script waits for the page to
107
+ finish loading, then fetches the chat widget from Asyntai, so your app draws
108
+ first and its speed is unchanged.
109
+
110
+ The widget ID and the script address travel to the browser on a data
111
+ attribute, never inside the script. The package works with a custom
112
+ `index_string`, with a subclass that overrides `interpolate_index`, and with
113
+ `url_base_pathname`. It never adds the widget twice.
114
+
115
+ ## Tests
116
+
117
+ ```
118
+ pip install -e . pytest
119
+ pytest
120
+ ```
121
+
122
+ ## Support
123
+
124
+ hello@asyntai.com
125
+
126
+ ## Licence
127
+
128
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,99 @@
1
+ # dash-ai-chatbot
2
+
3
+ Adds the [Asyntai](https://asyntai.com) AI chat assistant to a
4
+ [Plotly Dash](https://dash.plotly.com) app. The assistant answers visitor
5
+ questions from your own content, day and night, in more than 80 languages.
6
+
7
+ Free and open source. Works on every Asyntai plan, including the free plan.
8
+
9
+ - Requires Dash 2.0 or newer and Python 3.10 or newer
10
+ - Documentation: https://asyntai.com/documentation/integrations/plotly-dash/
11
+
12
+ ## Install
13
+
14
+ ```
15
+ pip install dash-ai-chatbot
16
+ ```
17
+
18
+ Add one line after you create the app:
19
+
20
+ ```python
21
+ from dash import Dash, html
22
+ import dash_ai_chatbot
23
+
24
+ app = Dash(__name__)
25
+ app.layout = html.Div("Hello")
26
+
27
+ dash_ai_chatbot.init_app(app, widget_id="asyntai_ab12cd34")
28
+
29
+ if __name__ == "__main__":
30
+ app.run()
31
+ ```
32
+
33
+ You get the ID from the Install page of your
34
+ [Asyntai dashboard](https://asyntai.com/dashboard). You can paste the whole
35
+ snippet; the package keeps only the ID.
36
+
37
+ That is all. The chat button appears on every page of the app, including
38
+ multi-page apps.
39
+
40
+ ## No code change (Dash 3 and later)
41
+
42
+ On Dash 3 and later the package also works with no code change. Install it and
43
+ set one environment variable:
44
+
45
+ ```
46
+ ASYNTAI_WIDGET_ID=asyntai_ab12cd34
47
+ ```
48
+
49
+ Every Dash app that runs with that variable gets the chat. Without the
50
+ variable the package does nothing.
51
+
52
+ ## Keep the ID out of the code
53
+
54
+ Leave out `widget_id` and the package reads `ASYNTAI_WIDGET_ID` each time the
55
+ page is built:
56
+
57
+ ```python
58
+ dash_ai_chatbot.init_app(app)
59
+ ```
60
+
61
+ ## Options
62
+
63
+ ```python
64
+ dash_ai_chatbot.init_app(
65
+ app,
66
+ widget_id="asyntai_ab12cd34", # or the whole snippet; empty or invalid = chat off
67
+ script_url=None, # change only if Asyntai support asks you to
68
+ )
69
+ ```
70
+
71
+ `script_url` can also come from the `ASYNTAI_SCRIPT_URL` environment variable.
72
+ Only an `https` address is accepted.
73
+
74
+ ## How it works
75
+
76
+ Dash serves one HTML page for the whole app. The package puts a small script
77
+ just before the closing body tag of that page. The script waits for the page to
78
+ finish loading, then fetches the chat widget from Asyntai, so your app draws
79
+ first and its speed is unchanged.
80
+
81
+ The widget ID and the script address travel to the browser on a data
82
+ attribute, never inside the script. The package works with a custom
83
+ `index_string`, with a subclass that overrides `interpolate_index`, and with
84
+ `url_base_pathname`. It never adds the widget twice.
85
+
86
+ ## Tests
87
+
88
+ ```
89
+ pip install -e . pytest
90
+ pytest
91
+ ```
92
+
93
+ ## Support
94
+
95
+ hello@asyntai.com
96
+
97
+ ## Licence
98
+
99
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dash-ai-chatbot"
7
+ version = "1.0.0"
8
+ description = "Adds the Asyntai AI chat assistant to a Plotly Dash app. It answers visitor questions from your own content, in more than 80 languages."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Asyntai", email = "hello@asyntai.com" }]
14
+ keywords = ["dash", "plotly", "plotly-dash", "chatbot", "ai", "ai-chatbot", "live-chat", "customer-support", "assistant", "widget"]
15
+ dependencies = ["dash>=2.0"]
16
+
17
+ classifiers = [
18
+ "Development Status :: 5 - Production/Stable",
19
+ "Environment :: Web Environment",
20
+ "Framework :: Dash",
21
+ "Framework :: Flask",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://asyntai.com"
34
+ Documentation = "https://asyntai.com/documentation/integrations/plotly-dash/"
35
+ Source = "https://github.com/asyntai/dash-ai-chatbot"
36
+ Issues = "https://github.com/asyntai/dash-ai-chatbot/issues"
37
+
38
+ # Dash 3 and later load this module when an app starts. It does nothing
39
+ # unless ASYNTAI_WIDGET_ID is set.
40
+ [project.entry-points.dash_hooks]
41
+ dash_ai_chatbot = "dash_ai_chatbot._hooks"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,70 @@
1
+ """Asyntai AI chat assistant for Plotly Dash apps.
2
+
3
+ Usage::
4
+
5
+ from dash import Dash, html
6
+ import dash_ai_chatbot
7
+
8
+ app = Dash(__name__)
9
+ app.layout = html.Div('Hello')
10
+ dash_ai_chatbot.init_app(app, widget_id='asyntai_ab12cd34')
11
+
12
+ Or, on Dash 3 and later, set ``ASYNTAI_WIDGET_ID`` in the environment and
13
+ change no code at all.
14
+ """
15
+ from ._core import (
16
+ DEFAULT_SCRIPT_URL,
17
+ clean_script_url,
18
+ clean_widget_id,
19
+ insert_widget,
20
+ widget_snippet,
21
+ )
22
+
23
+ __version__ = '1.0.0'
24
+
25
+ __all__ = [
26
+ 'DEFAULT_SCRIPT_URL',
27
+ 'clean_script_url',
28
+ 'clean_widget_id',
29
+ 'init_app',
30
+ 'insert_widget',
31
+ 'widget_snippet',
32
+ ]
33
+
34
+ _FLAG = '_asyntai_widget_settings'
35
+
36
+
37
+ def init_app(app, widget_id=None, script_url=None):
38
+ """Add the chat widget to every page of a Dash app.
39
+
40
+ ``widget_id`` is the ID from the Install page of the Asyntai dashboard,
41
+ or the whole snippet copied from there. When it is left out, the
42
+ ``ASYNTAI_WIDGET_ID`` environment variable is read each time the page is
43
+ built. ``script_url`` is only for a case where Asyntai support asks you
44
+ to change it.
45
+
46
+ Calling it twice on the same app only updates the values. The app is
47
+ returned, so the call can be chained.
48
+ """
49
+ if not hasattr(app, 'interpolate_index'):
50
+ raise TypeError('init_app() needs a dash.Dash app.')
51
+
52
+ settings = {'widget_id': widget_id, 'script_url': script_url}
53
+
54
+ if getattr(app, _FLAG, None) is not None:
55
+ getattr(app, _FLAG).update(settings)
56
+ return app
57
+
58
+ setattr(app, _FLAG, settings)
59
+ original = app.interpolate_index
60
+
61
+ def interpolate_index(**kwargs):
62
+ page = original(**kwargs)
63
+ current = getattr(app, _FLAG)
64
+ return insert_widget(page, current['widget_id'], current['script_url'])
65
+
66
+ # Wrapping the instance method works on Dash 2, 3 and 4, and it also
67
+ # works when the app sets its own index_string or overrides
68
+ # interpolate_index in a subclass.
69
+ app.interpolate_index = interpolate_index
70
+ return app
@@ -0,0 +1,144 @@
1
+ """Everything the package decides about the widget lives here.
2
+
3
+ Dash serves one HTML page for the whole app, built by ``Dash.interpolate_index``
4
+ and then passed through the ``index`` hooks. The widget goes into that page
5
+ once, just before the closing body tag, so it is on every view of the app,
6
+ including multi-page apps built with ``dash.page_registry``.
7
+ """
8
+ import html
9
+ import os
10
+ import re
11
+
12
+ DEFAULT_SCRIPT_URL = 'https://widget.asyntai.com/static/js/chat-widget.js'
13
+
14
+ #: Environment variables read when no value is passed in code.
15
+ ENV_WIDGET_ID = 'ASYNTAI_WIDGET_ID'
16
+ ENV_SCRIPT_URL = 'ASYNTAI_SCRIPT_URL'
17
+
18
+ #: Marker that shows the widget is already on the page.
19
+ MARKER = 'asyntai-widget-config'
20
+
21
+ #: Shape of a widget identifier from the Asyntai dashboard.
22
+ ID_PATTERN = re.compile(r'^asyntai_[A-Za-z0-9]{6,64}$')
23
+
24
+ #: The identifier inside a pasted snippet.
25
+ SNIPPET_PATTERN = re.compile(r'data-asyntai-id\s*=\s*["\']([^"\']+)["\']')
26
+
27
+ _HTTPS_PATTERN = re.compile(r'^https://[^\s"\'<>]+$', re.IGNORECASE)
28
+
29
+ BODY_CLOSE = '</body>'
30
+
31
+ _LOADER = """<div class="asyntai-widget-config" data-asyntai-widget="{widget_id}" data-asyntai-src="{script_url}" style="display: none"></div>
32
+ <script>
33
+ // Loads the Asyntai chat widget after the page has finished loading, so
34
+ // the Dash app draws first. The two values arrive on data attributes,
35
+ // because nothing is ever written into the script itself.
36
+ (function () {{
37
+ var config = document.querySelector('.asyntai-widget-config');
38
+ if (!config) {{ return; }}
39
+
40
+ var widgetId = config.getAttribute('data-asyntai-widget') || '';
41
+ var scriptUrl = config.getAttribute('data-asyntai-src') || '';
42
+ if (!widgetId || scriptUrl.indexOf('https://') !== 0) {{ return; }}
43
+
44
+ var load = function () {{
45
+ if (document.querySelector('script[data-asyntai-id]')) {{ return; }}
46
+
47
+ var script = document.createElement('script');
48
+ script.src = scriptUrl;
49
+ script.async = true;
50
+ script.setAttribute('data-asyntai-id', widgetId);
51
+ document.head.appendChild(script);
52
+ }};
53
+
54
+ if (document.readyState === 'complete') {{
55
+ load();
56
+ }} else {{
57
+ window.addEventListener('load', load);
58
+ }}
59
+ }}());
60
+ </script>
61
+ """
62
+
63
+
64
+ def clean_widget_id(raw):
65
+ """Return a usable widget identifier, or an empty string.
66
+
67
+ Accepts a bare identifier or the whole snippet from the Asyntai
68
+ dashboard, because people paste the snippet. An empty string means the
69
+ chat stays off.
70
+ """
71
+ if not isinstance(raw, str):
72
+ return ''
73
+
74
+ value = raw.strip()
75
+ if not value:
76
+ return ''
77
+
78
+ match = SNIPPET_PATTERN.search(value)
79
+ if match:
80
+ value = match.group(1).strip()
81
+
82
+ return value if ID_PATTERN.match(value) else ''
83
+
84
+
85
+ def clean_script_url(raw):
86
+ """Return the script address, or an empty string when the value is bad.
87
+
88
+ Only an https address is accepted, so a bad value can never put a
89
+ javascript: URL on the page. None or an empty value means the Asyntai
90
+ address.
91
+ """
92
+ if raw is None:
93
+ return DEFAULT_SCRIPT_URL
94
+ if not isinstance(raw, str):
95
+ return ''
96
+
97
+ value = raw.strip()
98
+ if not value:
99
+ return DEFAULT_SCRIPT_URL
100
+
101
+ return value if _HTTPS_PATTERN.match(value) else ''
102
+
103
+
104
+ def widget_snippet(widget_id, script_url=None):
105
+ """Return the HTML that loads the widget, or an empty string.
106
+
107
+ The empty string comes back when the identifier or the address is not
108
+ valid, so a typo leaves the app as it was instead of breaking it.
109
+ """
110
+ clean_id = clean_widget_id(widget_id)
111
+ clean_url = clean_script_url(script_url)
112
+ if not clean_id or not clean_url:
113
+ return ''
114
+
115
+ return _LOADER.format(
116
+ widget_id=html.escape(clean_id, quote=True),
117
+ script_url=html.escape(clean_url, quote=True),
118
+ )
119
+
120
+
121
+ def insert_widget(page, widget_id=None, script_url=None):
122
+ """Return the page with the widget added before the closing body tag.
123
+
124
+ Values passed in come first; missing ones are read from the environment
125
+ at the moment the page is built, so a deployment can change the ID
126
+ without a code change. The page is returned unchanged when there is no
127
+ valid ID, when it has no body tag, or when the widget is already on it.
128
+ """
129
+ if not isinstance(page, str) or MARKER in page:
130
+ return page
131
+
132
+ if widget_id is None:
133
+ widget_id = os.environ.get(ENV_WIDGET_ID, '')
134
+ if script_url is None:
135
+ script_url = os.environ.get(ENV_SCRIPT_URL) or None
136
+
137
+ snippet = widget_snippet(widget_id, script_url)
138
+ if not snippet or BODY_CLOSE not in page:
139
+ return page
140
+
141
+ # Split on the last closing tag, so a page that prints the tag inside an
142
+ # example or a code block is left alone.
143
+ head, _, tail = page.rpartition(BODY_CLOSE)
144
+ return head + snippet + BODY_CLOSE + tail
@@ -0,0 +1,25 @@
1
+ """Zero-code mode for Dash 3 and later.
2
+
3
+ Dash loads every module named in a ``dash_hooks`` entry point when an app
4
+ starts. This one registers an ``index`` hook, so the widget appears on every
5
+ Dash app on the machine once ``ASYNTAI_WIDGET_ID`` is set, with no code
6
+ change. Without that variable the hook leaves every page as it was.
7
+
8
+ Older Dash versions have no hooks; there ``init_app`` does the same job.
9
+ """
10
+ from ._core import insert_widget
11
+
12
+ try:
13
+ from dash import hooks
14
+ except ImportError: # Dash 2 has no hooks module.
15
+ hooks = None
16
+
17
+
18
+ def _add_widget(page):
19
+ # Only the environment is read here. An app that called init_app() has
20
+ # already got the widget, and insert_widget() sees the marker and stops.
21
+ return insert_widget(page)
22
+
23
+
24
+ if hooks is not None and hasattr(hooks, 'index'):
25
+ hooks.index()(_add_widget)
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: dash-ai-chatbot
3
+ Version: 1.0.0
4
+ Summary: Adds the Asyntai AI chat assistant to a Plotly Dash app. It answers visitor questions from your own content, in more than 80 languages.
5
+ Author-email: Asyntai <hello@asyntai.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://asyntai.com
8
+ Project-URL: Documentation, https://asyntai.com/documentation/integrations/plotly-dash/
9
+ Project-URL: Source, https://github.com/asyntai/dash-ai-chatbot
10
+ Project-URL: Issues, https://github.com/asyntai/dash-ai-chatbot/issues
11
+ Keywords: dash,plotly,plotly-dash,chatbot,ai,ai-chatbot,live-chat,customer-support,assistant,widget
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: Dash
15
+ Classifier: Framework :: Flask
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: dash>=2.0
28
+ Dynamic: license-file
29
+
30
+ # dash-ai-chatbot
31
+
32
+ Adds the [Asyntai](https://asyntai.com) AI chat assistant to a
33
+ [Plotly Dash](https://dash.plotly.com) app. The assistant answers visitor
34
+ questions from your own content, day and night, in more than 80 languages.
35
+
36
+ Free and open source. Works on every Asyntai plan, including the free plan.
37
+
38
+ - Requires Dash 2.0 or newer and Python 3.10 or newer
39
+ - Documentation: https://asyntai.com/documentation/integrations/plotly-dash/
40
+
41
+ ## Install
42
+
43
+ ```
44
+ pip install dash-ai-chatbot
45
+ ```
46
+
47
+ Add one line after you create the app:
48
+
49
+ ```python
50
+ from dash import Dash, html
51
+ import dash_ai_chatbot
52
+
53
+ app = Dash(__name__)
54
+ app.layout = html.Div("Hello")
55
+
56
+ dash_ai_chatbot.init_app(app, widget_id="asyntai_ab12cd34")
57
+
58
+ if __name__ == "__main__":
59
+ app.run()
60
+ ```
61
+
62
+ You get the ID from the Install page of your
63
+ [Asyntai dashboard](https://asyntai.com/dashboard). You can paste the whole
64
+ snippet; the package keeps only the ID.
65
+
66
+ That is all. The chat button appears on every page of the app, including
67
+ multi-page apps.
68
+
69
+ ## No code change (Dash 3 and later)
70
+
71
+ On Dash 3 and later the package also works with no code change. Install it and
72
+ set one environment variable:
73
+
74
+ ```
75
+ ASYNTAI_WIDGET_ID=asyntai_ab12cd34
76
+ ```
77
+
78
+ Every Dash app that runs with that variable gets the chat. Without the
79
+ variable the package does nothing.
80
+
81
+ ## Keep the ID out of the code
82
+
83
+ Leave out `widget_id` and the package reads `ASYNTAI_WIDGET_ID` each time the
84
+ page is built:
85
+
86
+ ```python
87
+ dash_ai_chatbot.init_app(app)
88
+ ```
89
+
90
+ ## Options
91
+
92
+ ```python
93
+ dash_ai_chatbot.init_app(
94
+ app,
95
+ widget_id="asyntai_ab12cd34", # or the whole snippet; empty or invalid = chat off
96
+ script_url=None, # change only if Asyntai support asks you to
97
+ )
98
+ ```
99
+
100
+ `script_url` can also come from the `ASYNTAI_SCRIPT_URL` environment variable.
101
+ Only an `https` address is accepted.
102
+
103
+ ## How it works
104
+
105
+ Dash serves one HTML page for the whole app. The package puts a small script
106
+ just before the closing body tag of that page. The script waits for the page to
107
+ finish loading, then fetches the chat widget from Asyntai, so your app draws
108
+ first and its speed is unchanged.
109
+
110
+ The widget ID and the script address travel to the browser on a data
111
+ attribute, never inside the script. The package works with a custom
112
+ `index_string`, with a subclass that overrides `interpolate_index`, and with
113
+ `url_base_pathname`. It never adds the widget twice.
114
+
115
+ ## Tests
116
+
117
+ ```
118
+ pip install -e . pytest
119
+ pytest
120
+ ```
121
+
122
+ ## Support
123
+
124
+ hello@asyntai.com
125
+
126
+ ## Licence
127
+
128
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ src/dash_ai_chatbot/__init__.py
6
+ src/dash_ai_chatbot/_core.py
7
+ src/dash_ai_chatbot/_hooks.py
8
+ src/dash_ai_chatbot.egg-info/PKG-INFO
9
+ src/dash_ai_chatbot.egg-info/SOURCES.txt
10
+ src/dash_ai_chatbot.egg-info/dependency_links.txt
11
+ src/dash_ai_chatbot.egg-info/entry_points.txt
12
+ src/dash_ai_chatbot.egg-info/requires.txt
13
+ src/dash_ai_chatbot.egg-info/top_level.txt
14
+ tests/test_core.py
15
+ tests/test_dash_app.py
@@ -0,0 +1,2 @@
1
+ [dash_hooks]
2
+ dash_ai_chatbot = dash_ai_chatbot._hooks
@@ -0,0 +1 @@
1
+ dash_ai_chatbot
@@ -0,0 +1,119 @@
1
+ """The rules for the two stored values and for placing the widget."""
2
+ import pytest
3
+
4
+ from dash_ai_chatbot import (
5
+ DEFAULT_SCRIPT_URL,
6
+ clean_script_url,
7
+ clean_widget_id,
8
+ insert_widget,
9
+ widget_snippet,
10
+ )
11
+
12
+ PAGE = '<!DOCTYPE html><html><head></head><body><div id="react-entry-point"></div></body></html>'
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def no_env(monkeypatch):
17
+ monkeypatch.delenv('ASYNTAI_WIDGET_ID', raising=False)
18
+ monkeypatch.delenv('ASYNTAI_SCRIPT_URL', raising=False)
19
+
20
+
21
+ @pytest.mark.parametrize('raw, expected', [
22
+ ('asyntai_ab12cd34', 'asyntai_ab12cd34'),
23
+ (' asyntai_ab12cd34 ', 'asyntai_ab12cd34'),
24
+ ('<script async src="https://widget.asyntai.com/static/js/chat-widget.js" data-asyntai-id="asyntai_ab12cd34"></script>', 'asyntai_ab12cd34'),
25
+ ("<script data-asyntai-id='asyntai_ab12cd34'></script>", 'asyntai_ab12cd34'),
26
+ ('asyntai_' + 'a' * 64, 'asyntai_' + 'a' * 64),
27
+ ])
28
+ def test_good_ids_are_kept(raw, expected):
29
+ assert clean_widget_id(raw) == expected
30
+
31
+
32
+ @pytest.mark.parametrize('raw', [
33
+ '', ' ', None, 123, ['asyntai_ab12cd34'],
34
+ 'ab12cd34', 'asyntai_', 'asyntai_ab12', 'asyntai_' + 'a' * 65,
35
+ 'asyntai_ab12cd34"><script>alert(1)</script>', 'asyntai_ab-12cd34',
36
+ ])
37
+ def test_bad_ids_switch_the_chat_off(raw):
38
+ assert clean_widget_id(raw) == ''
39
+
40
+
41
+ def test_script_url_defaults_to_asyntai():
42
+ assert clean_script_url(None) == DEFAULT_SCRIPT_URL
43
+ assert clean_script_url('') == DEFAULT_SCRIPT_URL
44
+ assert clean_script_url(' ') == DEFAULT_SCRIPT_URL
45
+
46
+
47
+ def test_only_https_script_urls_are_accepted():
48
+ assert clean_script_url('https://cdn.example.com/w.js') == 'https://cdn.example.com/w.js'
49
+ for bad in ['http://cdn.example.com/w.js', 'javascript:alert(1)', '//cdn.example.com/w.js',
50
+ 'https://a.com/"onload="x', 'https://a.com/w.js x', 42]:
51
+ assert clean_script_url(bad) == ''
52
+
53
+
54
+ def test_snippet_carries_the_values_on_data_attributes():
55
+ snippet = widget_snippet('asyntai_ab12cd34')
56
+ assert 'data-asyntai-widget="asyntai_ab12cd34"' in snippet
57
+ assert 'data-asyntai-src="%s"' % DEFAULT_SCRIPT_URL in snippet
58
+ script = snippet[snippet.index('<script>'):]
59
+ assert 'asyntai_ab12cd34' not in script
60
+
61
+
62
+ def test_snippet_is_empty_for_bad_values():
63
+ assert widget_snippet('nope') == ''
64
+ assert widget_snippet('asyntai_ab12cd34', 'http://x.com/w.js') == ''
65
+
66
+
67
+ def test_snippet_escapes_the_script_address():
68
+ snippet = widget_snippet('asyntai_ab12cd34', 'https://cdn.example.com/w.js?a=1&b=2')
69
+ assert 'data-asyntai-src="https://cdn.example.com/w.js?a=1&amp;b=2"' in snippet
70
+
71
+
72
+ def test_widget_goes_just_before_the_last_body_tag():
73
+ page = PAGE.replace('<body>', '<body><pre>&lt;/body&gt; </body> in a sample</pre>')
74
+ result = insert_widget(page, 'asyntai_ab12cd34')
75
+ assert result.count('class="asyntai-widget-config"') == 1
76
+ assert result.endswith('</script>\n</body></html>')
77
+ assert result.index('in a sample') < result.index('asyntai-widget-config')
78
+
79
+
80
+ def test_page_is_unchanged_without_a_valid_id():
81
+ assert insert_widget(PAGE) == PAGE
82
+ assert insert_widget(PAGE, 'bad') == PAGE
83
+ assert insert_widget(PAGE, '') == PAGE
84
+
85
+
86
+ def test_page_without_body_tag_is_unchanged():
87
+ page = '<div>partial</div>'
88
+ assert insert_widget(page, 'asyntai_ab12cd34') == page
89
+
90
+
91
+ def test_widget_is_never_added_twice():
92
+ once = insert_widget(PAGE, 'asyntai_ab12cd34')
93
+ assert insert_widget(once, 'asyntai_zz99yy88') == once
94
+
95
+
96
+ def test_non_string_page_is_returned_as_is():
97
+ assert insert_widget(None, 'asyntai_ab12cd34') is None
98
+
99
+
100
+ def test_environment_is_used_when_no_id_is_passed(monkeypatch):
101
+ monkeypatch.setenv('ASYNTAI_WIDGET_ID', 'asyntai_env12345')
102
+ assert 'data-asyntai-widget="asyntai_env12345"' in insert_widget(PAGE)
103
+
104
+
105
+ def test_passed_id_beats_the_environment(monkeypatch):
106
+ monkeypatch.setenv('ASYNTAI_WIDGET_ID', 'asyntai_env12345')
107
+ result = insert_widget(PAGE, 'asyntai_code1234')
108
+ assert 'asyntai_code1234' in result
109
+ assert 'asyntai_env12345' not in result
110
+
111
+
112
+ def test_script_address_can_come_from_the_environment(monkeypatch):
113
+ monkeypatch.setenv('ASYNTAI_SCRIPT_URL', 'https://cdn.example.com/w.js')
114
+ assert 'data-asyntai-src="https://cdn.example.com/w.js"' in insert_widget(PAGE, 'asyntai_ab12cd34')
115
+
116
+
117
+ def test_bad_script_address_in_the_environment_keeps_the_chat_off(monkeypatch):
118
+ monkeypatch.setenv('ASYNTAI_SCRIPT_URL', 'http://cdn.example.com/w.js')
119
+ assert insert_widget(PAGE, 'asyntai_ab12cd34') == PAGE
@@ -0,0 +1,175 @@
1
+ """The widget on the page a real Dash app serves."""
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import tempfile
6
+
7
+ import dash
8
+ import pytest
9
+ from dash import Dash, html
10
+
11
+ import dash_ai_chatbot
12
+
13
+ WIDGET = 'class="asyntai-widget-config"'
14
+ HAS_HOOKS = hasattr(dash, 'hooks') and hasattr(getattr(dash, 'hooks'), 'index')
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def no_env(monkeypatch):
19
+ monkeypatch.delenv('ASYNTAI_WIDGET_ID', raising=False)
20
+ monkeypatch.delenv('ASYNTAI_SCRIPT_URL', raising=False)
21
+
22
+
23
+ def make_app(**kwargs):
24
+ app = Dash(__name__, **kwargs)
25
+ app.layout = html.Div('Hello from Dash', id='hello')
26
+ return app
27
+
28
+
29
+ def page(app, path='/'):
30
+ response = app.server.test_client().get(path)
31
+ assert response.status_code == 200
32
+ return response.get_data(as_text=True)
33
+
34
+
35
+ def test_init_app_puts_the_widget_on_the_page():
36
+ app = make_app()
37
+ assert dash_ai_chatbot.init_app(app, widget_id='asyntai_ab12cd34') is app
38
+
39
+ body = page(app)
40
+ assert body.count(WIDGET) == 1
41
+ assert 'data-asyntai-widget="asyntai_ab12cd34"' in body
42
+ # Dash's own parts are still there, so the app still starts.
43
+ assert 'id="react-entry-point"' in body
44
+ assert body.index(WIDGET) > body.index('id="react-entry-point"')
45
+ assert body.rstrip().endswith('</html>')
46
+
47
+
48
+ def test_a_pasted_snippet_works():
49
+ app = make_app()
50
+ dash_ai_chatbot.init_app(app, widget_id='<script async src="x" data-asyntai-id="asyntai_ab12cd34"></script>')
51
+ assert 'data-asyntai-widget="asyntai_ab12cd34"' in page(app)
52
+
53
+
54
+ def test_a_bad_id_leaves_the_app_as_it_was():
55
+ # Dash 4 puts a fresh token on every page, so two loads never match
56
+ # byte for byte. Check that nothing of ours is on the page instead.
57
+ app = make_app()
58
+ dash_ai_chatbot.init_app(app, widget_id='not-an-id')
59
+ body = page(app)
60
+ assert 'asyntai' not in body.lower()
61
+ assert 'id="react-entry-point"' in body
62
+
63
+
64
+ def test_calling_init_app_twice_only_updates_the_id():
65
+ app = make_app()
66
+ dash_ai_chatbot.init_app(app, widget_id='asyntai_first123')
67
+ dash_ai_chatbot.init_app(app, widget_id='asyntai_second12')
68
+ body = page(app)
69
+ assert body.count(WIDGET) == 1
70
+ assert 'asyntai_second12' in body
71
+ assert 'asyntai_first123' not in body
72
+
73
+
74
+ def test_init_app_reads_the_environment_on_each_page_build(monkeypatch):
75
+ app = make_app()
76
+ dash_ai_chatbot.init_app(app)
77
+ assert WIDGET not in page(app)
78
+
79
+ monkeypatch.setenv('ASYNTAI_WIDGET_ID', 'asyntai_env12345')
80
+ assert 'data-asyntai-widget="asyntai_env12345"' in page(app)
81
+
82
+
83
+ def test_init_app_rejects_something_that_is_not_an_app():
84
+ with pytest.raises(TypeError):
85
+ dash_ai_chatbot.init_app(object(), widget_id='asyntai_ab12cd34')
86
+
87
+
88
+ def test_custom_index_string():
89
+ app = make_app()
90
+ app.index_string = """<!DOCTYPE html>
91
+ <html>
92
+ <head>{%metas%}<title>Custom</title>{%favicon%}{%css%}</head>
93
+ <body>
94
+ <header>My header</header>
95
+ {%app_entry%}
96
+ <footer>{%config%}{%scripts%}{%renderer%}</footer>
97
+ </body>
98
+ </html>"""
99
+ dash_ai_chatbot.init_app(app, widget_id='asyntai_ab12cd34')
100
+ body = page(app)
101
+ assert 'My header' in body
102
+ assert body.count(WIDGET) == 1
103
+ assert body.index('</footer>') < body.index(WIDGET)
104
+
105
+
106
+ def test_subclass_that_overrides_interpolate_index():
107
+ class MyDash(Dash):
108
+ def interpolate_index(self, **kwargs):
109
+ return (
110
+ '<!DOCTYPE html><html><head>{metas}{title}{favicon}{css}</head>'
111
+ '<body><p>Subclass page</p>{app_entry}{config}{scripts}{renderer}</body></html>'
112
+ ).format(**kwargs)
113
+
114
+ app = MyDash(__name__)
115
+ app.layout = html.Div('x')
116
+ dash_ai_chatbot.init_app(app, widget_id='asyntai_ab12cd34')
117
+ body = page(app)
118
+ assert 'Subclass page' in body
119
+ assert body.count(WIDGET) == 1
120
+
121
+
122
+ def test_url_base_pathname():
123
+ app = make_app(url_base_pathname='/reports/')
124
+ dash_ai_chatbot.init_app(app, widget_id='asyntai_ab12cd34')
125
+ assert WIDGET in page(app, '/reports/')
126
+
127
+
128
+ def test_two_apps_keep_their_own_ids():
129
+ one, two = make_app(), make_app()
130
+ dash_ai_chatbot.init_app(one, widget_id='asyntai_appone12')
131
+ dash_ai_chatbot.init_app(two, widget_id='asyntai_apptwo12')
132
+ assert 'asyntai_appone12' in page(one) and 'asyntai_apptwo12' not in page(one)
133
+ assert 'asyntai_apptwo12' in page(two) and 'asyntai_appone12' not in page(two)
134
+
135
+
136
+ @pytest.mark.skipif(not HAS_HOOKS, reason='this Dash version has no hooks')
137
+ def test_no_code_mode_needs_the_environment_variable(monkeypatch):
138
+ app = make_app()
139
+ assert WIDGET not in page(app)
140
+
141
+ monkeypatch.setenv('ASYNTAI_WIDGET_ID', 'asyntai_env12345')
142
+ body = page(make_app())
143
+ assert body.count(WIDGET) == 1
144
+ assert 'data-asyntai-widget="asyntai_env12345"' in body
145
+
146
+
147
+ @pytest.mark.skipif(not HAS_HOOKS, reason='this Dash version has no hooks')
148
+ def test_init_app_and_no_code_mode_never_add_two_widgets(monkeypatch):
149
+ monkeypatch.setenv('ASYNTAI_WIDGET_ID', 'asyntai_env12345')
150
+ app = make_app()
151
+ dash_ai_chatbot.init_app(app, widget_id='asyntai_code1234')
152
+ body = page(app)
153
+ assert body.count(WIDGET) == 1
154
+ assert 'asyntai_code1234' in body
155
+
156
+
157
+ def test_the_script_on_the_page_parses():
158
+ node = shutil.which('node')
159
+ if not node:
160
+ pytest.skip('node is not installed')
161
+
162
+ app = make_app()
163
+ dash_ai_chatbot.init_app(app, widget_id='asyntai_ab12cd34')
164
+ body = page(app)
165
+ start = body.index('<script>', body.index(WIDGET)) + len('<script>')
166
+ source = body[start:body.index('</script>', start)]
167
+
168
+ handle, path = tempfile.mkstemp(suffix='.js')
169
+ with os.fdopen(handle, 'w', encoding='utf-8') as fh:
170
+ fh.write(source)
171
+ try:
172
+ result = subprocess.run([node, '--check', path], capture_output=True, text=True)
173
+ finally:
174
+ os.unlink(path)
175
+ assert result.returncode == 0, result.stderr