markanm 0.1.0a1__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.
- markanm-0.1.0a1/LICENSE +21 -0
- markanm-0.1.0a1/PKG-INFO +163 -0
- markanm-0.1.0a1/README.md +115 -0
- markanm-0.1.0a1/pyproject.toml +36 -0
- markanm-0.1.0a1/setup.cfg +4 -0
- markanm-0.1.0a1/setup.py +8 -0
- markanm-0.1.0a1/src/markanm/__init__.py +41 -0
- markanm-0.1.0a1/src/markanm/ai.py +73 -0
- markanm-0.1.0a1/src/markanm/bot.py +188 -0
- markanm-0.1.0a1/src/markanm/client.py +128 -0
- markanm-0.1.0a1/src/markanm/context.py +54 -0
- markanm-0.1.0a1/src/markanm/events.py +30 -0
- markanm-0.1.0a1/src/markanm/exceptions.py +53 -0
- markanm-0.1.0a1/src/markanm/webhook.py +78 -0
- markanm-0.1.0a1/src/markanm.egg-info/PKG-INFO +163 -0
- markanm-0.1.0a1/src/markanm.egg-info/SOURCES.txt +22 -0
- markanm-0.1.0a1/src/markanm.egg-info/dependency_links.txt +1 -0
- markanm-0.1.0a1/src/markanm.egg-info/requires.txt +1 -0
- markanm-0.1.0a1/src/markanm.egg-info/top_level.txt +1 -0
- markanm-0.1.0a1/tests/test_ai.py +14 -0
- markanm-0.1.0a1/tests/test_bot.py +39 -0
- markanm-0.1.0a1/tests/test_context.py +15 -0
- markanm-0.1.0a1/tests/test_events.py +16 -0
- markanm-0.1.0a1/tests/test_webhook.py +42 -0
markanm-0.1.0a1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MarkanM Developer Team
|
|
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.
|
markanm-0.1.0a1/PKG-INFO
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: markanm
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Official Python SDK for MarkanM Chat Bot Platform & REST API (v0.1.0-alpha)
|
|
5
|
+
Author-email: MarkanM Developer Team <developers@markanm.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 MarkanM Developer Team
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://chat.markanm.com/developers
|
|
29
|
+
Project-URL: Documentation, https://chat.markanm.com/developers/docs
|
|
30
|
+
Project-URL: Repository, https://github.com/markanm/markanm-python-sdk
|
|
31
|
+
Keywords: markanm,bot,chat,sdk,api,oauth,webhooks
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Topic :: Communications :: Chat
|
|
42
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
43
|
+
Requires-Python: >=3.8
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
License-File: LICENSE
|
|
46
|
+
Requires-Dist: httpx>=0.24.0
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
|
|
49
|
+
# MarkanM Official Python SDK (`markanm 0.1.0a1`)
|
|
50
|
+
|
|
51
|
+
Official Python library (v0.1.0-alpha) for building developer bots, automation, and AI agents on **MarkanM Chat** (`https://chat.markanm.com`).
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## ⚠️ Alpha Release Notice
|
|
56
|
+
|
|
57
|
+
This is **v0.1.0-alpha** (`markanm 0.1.0a1`). It is intended for testing against the MarkanM Bot API v1.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 🚀 Installation
|
|
62
|
+
|
|
63
|
+
Install via `pip`:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install markanm
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## ⚡ Quick Start
|
|
72
|
+
|
|
73
|
+
### 1. Simple Hello Bot (`hello_bot.py`)
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import os
|
|
77
|
+
from markanm import Bot
|
|
78
|
+
|
|
79
|
+
bot = Bot(os.environ["MARKANM_BOT_TOKEN"])
|
|
80
|
+
|
|
81
|
+
@bot.command("hello")
|
|
82
|
+
async def hello(ctx):
|
|
83
|
+
await ctx.reply("Hello 👋 Welcome to MarkanM!")
|
|
84
|
+
|
|
85
|
+
@bot.command("start")
|
|
86
|
+
async def start(ctx):
|
|
87
|
+
await ctx.reply("🚀 Bot activated and ready!")
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
bot.run_polling()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
### 2. Event Listeners
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
@bot.on("message.created")
|
|
99
|
+
async def on_message(event):
|
|
100
|
+
print(f"New message received: {event.payload}")
|
|
101
|
+
|
|
102
|
+
@bot.on("member.joined")
|
|
103
|
+
def on_member_joined(event):
|
|
104
|
+
print(f"User joined room: {event.payload.get('user')}")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
### 3. AI Assistant Bot (`ai_bot.py`)
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
import os
|
|
113
|
+
from markanm import Bot
|
|
114
|
+
from markanm.ai import AI
|
|
115
|
+
|
|
116
|
+
bot = Bot(os.environ["MARKANM_BOT_TOKEN"])
|
|
117
|
+
ai = AI(provider="openai", api_key=os.environ["OPENAI_API_KEY"])
|
|
118
|
+
|
|
119
|
+
@bot.command("ask")
|
|
120
|
+
async def ask(ctx):
|
|
121
|
+
prompt = " ".join(ctx.args)
|
|
122
|
+
if not prompt:
|
|
123
|
+
await ctx.reply("Usage: /ask <question>")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
answer = await ai.generate(prompt)
|
|
127
|
+
await ctx.reply(answer)
|
|
128
|
+
|
|
129
|
+
bot.run_polling()
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
### 4. Webhook Verification (Flask / FastAPI)
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from markanm import WebhookHandler, MarkanMWebhookError
|
|
138
|
+
|
|
139
|
+
handler = WebhookHandler(secret="whsec_YOUR_SECRET", timestamp_tolerance=300)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
# Verifies HMAC-SHA256 signature header and 300s timestamp tolerance
|
|
143
|
+
event = handler.process_event(request_body_text, request_headers.get("X-MarkanM-Signature"))
|
|
144
|
+
print(f"Event verified: {event.id} ({event.type})")
|
|
145
|
+
except MarkanMWebhookError as err:
|
|
146
|
+
print(f"Webhook error: {err}")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 🧪 Testing
|
|
152
|
+
|
|
153
|
+
Run tests with `pytest`:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pytest sdk/python/tests/
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 📚 API Reference
|
|
162
|
+
For complete API endpoints and permission scope guidelines, visit:
|
|
163
|
+
**https://chat.markanm.com/developers/docs**
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# MarkanM Official Python SDK (`markanm 0.1.0a1`)
|
|
2
|
+
|
|
3
|
+
Official Python library (v0.1.0-alpha) for building developer bots, automation, and AI agents on **MarkanM Chat** (`https://chat.markanm.com`).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## ⚠️ Alpha Release Notice
|
|
8
|
+
|
|
9
|
+
This is **v0.1.0-alpha** (`markanm 0.1.0a1`). It is intended for testing against the MarkanM Bot API v1.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 🚀 Installation
|
|
14
|
+
|
|
15
|
+
Install via `pip`:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install markanm
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## ⚡ Quick Start
|
|
24
|
+
|
|
25
|
+
### 1. Simple Hello Bot (`hello_bot.py`)
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import os
|
|
29
|
+
from markanm import Bot
|
|
30
|
+
|
|
31
|
+
bot = Bot(os.environ["MARKANM_BOT_TOKEN"])
|
|
32
|
+
|
|
33
|
+
@bot.command("hello")
|
|
34
|
+
async def hello(ctx):
|
|
35
|
+
await ctx.reply("Hello 👋 Welcome to MarkanM!")
|
|
36
|
+
|
|
37
|
+
@bot.command("start")
|
|
38
|
+
async def start(ctx):
|
|
39
|
+
await ctx.reply("🚀 Bot activated and ready!")
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
bot.run_polling()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
### 2. Event Listeners
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
@bot.on("message.created")
|
|
51
|
+
async def on_message(event):
|
|
52
|
+
print(f"New message received: {event.payload}")
|
|
53
|
+
|
|
54
|
+
@bot.on("member.joined")
|
|
55
|
+
def on_member_joined(event):
|
|
56
|
+
print(f"User joined room: {event.payload.get('user')}")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### 3. AI Assistant Bot (`ai_bot.py`)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import os
|
|
65
|
+
from markanm import Bot
|
|
66
|
+
from markanm.ai import AI
|
|
67
|
+
|
|
68
|
+
bot = Bot(os.environ["MARKANM_BOT_TOKEN"])
|
|
69
|
+
ai = AI(provider="openai", api_key=os.environ["OPENAI_API_KEY"])
|
|
70
|
+
|
|
71
|
+
@bot.command("ask")
|
|
72
|
+
async def ask(ctx):
|
|
73
|
+
prompt = " ".join(ctx.args)
|
|
74
|
+
if not prompt:
|
|
75
|
+
await ctx.reply("Usage: /ask <question>")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
answer = await ai.generate(prompt)
|
|
79
|
+
await ctx.reply(answer)
|
|
80
|
+
|
|
81
|
+
bot.run_polling()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
### 4. Webhook Verification (Flask / FastAPI)
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from markanm import WebhookHandler, MarkanMWebhookError
|
|
90
|
+
|
|
91
|
+
handler = WebhookHandler(secret="whsec_YOUR_SECRET", timestamp_tolerance=300)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
# Verifies HMAC-SHA256 signature header and 300s timestamp tolerance
|
|
95
|
+
event = handler.process_event(request_body_text, request_headers.get("X-MarkanM-Signature"))
|
|
96
|
+
print(f"Event verified: {event.id} ({event.type})")
|
|
97
|
+
except MarkanMWebhookError as err:
|
|
98
|
+
print(f"Webhook error: {err}")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 🧪 Testing
|
|
104
|
+
|
|
105
|
+
Run tests with `pytest`:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pytest sdk/python/tests/
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 📚 API Reference
|
|
114
|
+
For complete API endpoints and permission scope guidelines, visit:
|
|
115
|
+
**https://chat.markanm.com/developers/docs**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "markanm"
|
|
7
|
+
version = "0.1.0a1"
|
|
8
|
+
description = "Official Python SDK for MarkanM Chat Bot Platform & REST API (v0.1.0-alpha)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "MarkanM Developer Team", email = "developers@markanm.com" }
|
|
12
|
+
]
|
|
13
|
+
license = { file = "LICENSE" }
|
|
14
|
+
requires-python = ">=3.8"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Communications :: Chat",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules"
|
|
27
|
+
]
|
|
28
|
+
keywords = ["markanm", "bot", "chat", "sdk", "api", "oauth", "webhooks"]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"httpx>=0.24.0"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://chat.markanm.com/developers"
|
|
35
|
+
Documentation = "https://chat.markanm.com/developers/docs"
|
|
36
|
+
Repository = "https://github.com/markanm/markanm-python-sdk"
|
markanm-0.1.0a1/setup.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MarkanM Official Python SDK (v0.1.0-alpha)
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Official Python library for building developer bots, automation, and AI agents on MarkanM Chat.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0a1"
|
|
9
|
+
__author__ = "MarkanM Developer Team"
|
|
10
|
+
|
|
11
|
+
from .bot import Bot
|
|
12
|
+
from .context import CommandContext
|
|
13
|
+
from .events import Event
|
|
14
|
+
from .webhook import WebhookHandler
|
|
15
|
+
from .ai import AI
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
MarkanMError,
|
|
18
|
+
MarkanMAPIError,
|
|
19
|
+
MarkanMAuthError,
|
|
20
|
+
MarkanMPermissionError,
|
|
21
|
+
MarkanMNotFoundError,
|
|
22
|
+
MarkanMRateLimitError,
|
|
23
|
+
MarkanMWebhookError,
|
|
24
|
+
MarkanMValidationError
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Bot",
|
|
29
|
+
"CommandContext",
|
|
30
|
+
"Event",
|
|
31
|
+
"WebhookHandler",
|
|
32
|
+
"AI",
|
|
33
|
+
"MarkanMError",
|
|
34
|
+
"MarkanMAPIError",
|
|
35
|
+
"MarkanMAuthError",
|
|
36
|
+
"MarkanMPermissionError",
|
|
37
|
+
"MarkanMNotFoundError",
|
|
38
|
+
"MarkanMRateLimitError",
|
|
39
|
+
"MarkanMWebhookError",
|
|
40
|
+
"MarkanMValidationError"
|
|
41
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from .exceptions import MarkanMError, _mask_sensitive
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import httpx
|
|
8
|
+
HAS_HTTPX = True
|
|
9
|
+
except ImportError:
|
|
10
|
+
HAS_HTTPX = False
|
|
11
|
+
import urllib.request
|
|
12
|
+
import urllib.error
|
|
13
|
+
|
|
14
|
+
class AI:
|
|
15
|
+
"""
|
|
16
|
+
AI Integration Layer for MarkanM Developer Bots.
|
|
17
|
+
Allows developers to integrate OpenAI or custom HTTP AI model endpoints using their own credentials.
|
|
18
|
+
"""
|
|
19
|
+
def __init__(self, provider: str = "openai", api_key: str = "", model: str = "gpt-3.5-turbo", base_url: Optional[str] = None):
|
|
20
|
+
self.provider = provider.lower().strip()
|
|
21
|
+
self.api_key = api_key
|
|
22
|
+
self.model = model
|
|
23
|
+
self.base_url = base_url or "https://api.openai.com/v1"
|
|
24
|
+
|
|
25
|
+
async def generate(self, prompt: str, system_prompt: str = "You are a helpful MarkanM Bot assistant.") -> str:
|
|
26
|
+
"""
|
|
27
|
+
Generate text response from configured AI provider
|
|
28
|
+
"""
|
|
29
|
+
if not prompt or not isinstance(prompt, str):
|
|
30
|
+
raise MarkanMError("Prompt must be a non-empty string")
|
|
31
|
+
|
|
32
|
+
if self.provider == "openai":
|
|
33
|
+
if not self.api_key:
|
|
34
|
+
raise MarkanMError("OpenAI API Key is required for OpenAI AI provider")
|
|
35
|
+
|
|
36
|
+
headers = {
|
|
37
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
38
|
+
"Content-Type": "application/json"
|
|
39
|
+
}
|
|
40
|
+
payload = {
|
|
41
|
+
"model": self.model,
|
|
42
|
+
"messages": [
|
|
43
|
+
{"role": "system", "content": system_prompt},
|
|
44
|
+
{"role": "user", "content": prompt}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if HAS_HTTPX:
|
|
49
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
50
|
+
try:
|
|
51
|
+
res = await client.post(f"{self.base_url.rstrip('/')}/chat/completions", json=payload, headers=headers)
|
|
52
|
+
if res.is_success:
|
|
53
|
+
data = res.json()
|
|
54
|
+
return data["choices"][0]["message"]["content"].strip()
|
|
55
|
+
else:
|
|
56
|
+
raise MarkanMError(f"AI Provider HTTP {res.status_code}: {res.text}")
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
raise MarkanMError(f"AI Provider request failed: {_mask_sensitive(str(exc))}")
|
|
59
|
+
else:
|
|
60
|
+
def _sync_ai_request():
|
|
61
|
+
req_data = json.dumps(payload).encode("utf-8")
|
|
62
|
+
req = urllib.request.Request(f"{self.base_url.rstrip('/')}/chat/completions", data=req_data, headers=headers, method="POST")
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
65
|
+
res_text = resp.read().decode("utf-8")
|
|
66
|
+
data = json.loads(res_text)
|
|
67
|
+
return data["choices"][0]["message"]["content"].strip()
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
raise MarkanMError(f"AI Provider request failed: {_mask_sensitive(str(exc))}")
|
|
70
|
+
|
|
71
|
+
return await asyncio.to_thread(_sync_ai_request)
|
|
72
|
+
|
|
73
|
+
raise MarkanMError(f"Unsupported AI provider '{self.provider}'. Supported providers: 'openai'")
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import signal
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Callable, Dict, List, Any, Optional
|
|
6
|
+
from .client import APIClient
|
|
7
|
+
from .context import CommandContext
|
|
8
|
+
from .events import Event
|
|
9
|
+
from .webhook import WebhookHandler
|
|
10
|
+
from .exceptions import MarkanMError, _mask_sensitive
|
|
11
|
+
|
|
12
|
+
class Bot:
|
|
13
|
+
"""
|
|
14
|
+
Official MarkanM Bot Client (v0.1.0-alpha)
|
|
15
|
+
"""
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
token: str,
|
|
19
|
+
base_url: str = "https://chat.markanm.com/api/bot/v1",
|
|
20
|
+
timeout: float = 10.0,
|
|
21
|
+
webhook_secret: Optional[str] = None
|
|
22
|
+
):
|
|
23
|
+
if not token or not isinstance(token, str):
|
|
24
|
+
raise MarkanMError("Bot token must be a non-empty string starting with 'mkbot_...'")
|
|
25
|
+
|
|
26
|
+
self.token = token
|
|
27
|
+
self.client = APIClient(token=token, base_url=base_url, timeout=timeout)
|
|
28
|
+
self.webhook_secret = webhook_secret
|
|
29
|
+
self.commands: Dict[str, Callable] = {}
|
|
30
|
+
self.listeners: Dict[str, List[Callable]] = {}
|
|
31
|
+
self._is_running = False
|
|
32
|
+
|
|
33
|
+
def command(self, name: str):
|
|
34
|
+
"""
|
|
35
|
+
Decorator to register a bot command handler.
|
|
36
|
+
Usage:
|
|
37
|
+
@bot.command("hello")
|
|
38
|
+
async def hello_handler(ctx):
|
|
39
|
+
await ctx.reply("Hello 👋")
|
|
40
|
+
"""
|
|
41
|
+
clean_name = name.lstrip("/").strip().lower()
|
|
42
|
+
def decorator(func: Callable):
|
|
43
|
+
self.commands[clean_name] = func
|
|
44
|
+
return func
|
|
45
|
+
return decorator
|
|
46
|
+
|
|
47
|
+
def on(self, event_type: str):
|
|
48
|
+
"""
|
|
49
|
+
Decorator to register an event listener. Supports multiple listeners per event.
|
|
50
|
+
Usage:
|
|
51
|
+
@bot.on("message.created")
|
|
52
|
+
async def on_message(evt):
|
|
53
|
+
print(evt.type)
|
|
54
|
+
"""
|
|
55
|
+
clean_type = event_type.strip()
|
|
56
|
+
def decorator(func: Callable):
|
|
57
|
+
if clean_type not in self.listeners:
|
|
58
|
+
self.listeners[clean_type] = []
|
|
59
|
+
self.listeners[clean_type].append(func)
|
|
60
|
+
return func
|
|
61
|
+
return decorator
|
|
62
|
+
|
|
63
|
+
async def get_me() -> Dict[str, Any]:
|
|
64
|
+
"""Fetch bot's profile"""
|
|
65
|
+
return await self.client.get("/me")
|
|
66
|
+
|
|
67
|
+
async def get_room(self, room_id: str) -> Dict[str, Any]:
|
|
68
|
+
"""Fetch room details"""
|
|
69
|
+
return await self.client.get(f"/rooms/{room_id}")
|
|
70
|
+
|
|
71
|
+
async def get_room_members(self, room_id: str) -> Dict[str, Any]:
|
|
72
|
+
"""Fetch room members"""
|
|
73
|
+
return await self.client.get(f"/rooms/{room_id}/members")
|
|
74
|
+
|
|
75
|
+
async def send_message(self, room_id: str, text: str, card: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
76
|
+
"""Send a text or interactive card message to a room"""
|
|
77
|
+
payload = {"text": text}
|
|
78
|
+
if card:
|
|
79
|
+
payload["card"] = card
|
|
80
|
+
payload["type"] = "card"
|
|
81
|
+
return await self.client.post(f"/rooms/{room_id}/messages", json_data=payload)
|
|
82
|
+
|
|
83
|
+
async def reply(self, message_id: str, text: str) -> Dict[str, Any]:
|
|
84
|
+
"""Reply directly to a message"""
|
|
85
|
+
return await self.client.post(f"/messages/{message_id}/reply", json_data={"text": text})
|
|
86
|
+
|
|
87
|
+
async def react(self, message_id: str, emoji: str = "👍") -> Dict[str, Any]:
|
|
88
|
+
"""Toggle emoji reaction on a message"""
|
|
89
|
+
return await self.client.post(f"/messages/{message_id}/react", json_data={"emoji": emoji})
|
|
90
|
+
|
|
91
|
+
async def delete_message(self, message_id: str) -> Dict[str, Any]:
|
|
92
|
+
"""Delete a message"""
|
|
93
|
+
return await self.client.delete(f"/messages/{message_id}")
|
|
94
|
+
|
|
95
|
+
async def dispatch_event(self, event: Event):
|
|
96
|
+
"""
|
|
97
|
+
Dispatch incoming Event to registered event listeners & command handlers
|
|
98
|
+
"""
|
|
99
|
+
evt_type = event.type
|
|
100
|
+
|
|
101
|
+
# 1. Execute general event listeners
|
|
102
|
+
if evt_type in self.listeners:
|
|
103
|
+
for listener in self.listeners[evt_type]:
|
|
104
|
+
await self._invoke_handler(listener, event)
|
|
105
|
+
|
|
106
|
+
# 2. Execute command handler if event is 'command.received'
|
|
107
|
+
if evt_type == "command.received" or (isinstance(event.payload, dict) and "command" in event.payload):
|
|
108
|
+
cmd_payload = event.payload if isinstance(event.payload, dict) else {}
|
|
109
|
+
cmd_name = str(cmd_payload.get("command", "")).lstrip("/").strip().lower()
|
|
110
|
+
|
|
111
|
+
if cmd_name in self.commands:
|
|
112
|
+
ctx = CommandContext(self, cmd_payload)
|
|
113
|
+
handler = self.commands[cmd_name]
|
|
114
|
+
await self._invoke_handler(handler, ctx)
|
|
115
|
+
|
|
116
|
+
async def _invoke_handler(self, handler: Callable, arg: Any):
|
|
117
|
+
"""Helper to invoke sync or async handler safely"""
|
|
118
|
+
try:
|
|
119
|
+
if inspect.iscoroutinefunction(handler):
|
|
120
|
+
await handler(arg)
|
|
121
|
+
else:
|
|
122
|
+
loop = asyncio.get_running_loop()
|
|
123
|
+
await loop.run_in_executor(None, handler, arg)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
safe_msg = _mask_sensitive(str(exc))
|
|
126
|
+
print(f"⚠️ Error executing handler {handler.__name__}: {safe_msg}")
|
|
127
|
+
|
|
128
|
+
def verify_webhook(self, payload_body: str, signature_header: str, secret: Optional[str] = None) -> bool:
|
|
129
|
+
"""
|
|
130
|
+
Verify incoming webhook signature
|
|
131
|
+
"""
|
|
132
|
+
sec = secret or self.webhook_secret
|
|
133
|
+
if not sec:
|
|
134
|
+
raise MarkanMError("Webhook secret must be specified to verify signatures")
|
|
135
|
+
handler = WebhookHandler(secret=sec)
|
|
136
|
+
return handler.verify(payload_body, signature_header)
|
|
137
|
+
|
|
138
|
+
async def handle_webhook(self, payload_body: str, signature_header: str, secret: Optional[str] = None) -> Event:
|
|
139
|
+
"""
|
|
140
|
+
Verify signature, parse Event, and dispatch to handlers
|
|
141
|
+
"""
|
|
142
|
+
sec = secret or self.webhook_secret
|
|
143
|
+
if not sec:
|
|
144
|
+
raise MarkanMError("Webhook secret must be specified to process webhooks")
|
|
145
|
+
|
|
146
|
+
wh_handler = WebhookHandler(secret=sec)
|
|
147
|
+
event = wh_handler.process_event(payload_body, signature_header)
|
|
148
|
+
await self.dispatch_event(event)
|
|
149
|
+
return event
|
|
150
|
+
|
|
151
|
+
def run_polling(self, interval: float = 2.0):
|
|
152
|
+
"""
|
|
153
|
+
Run development polling event loop
|
|
154
|
+
"""
|
|
155
|
+
print(f"🤖 MarkanM Bot running in development polling mode (interval: {interval}s)...")
|
|
156
|
+
self._is_running = True
|
|
157
|
+
loop = asyncio.get_event_loop()
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
loop.run_until_complete(self._polling_loop(interval))
|
|
161
|
+
except (KeyboardInterrupt, SystemExit):
|
|
162
|
+
print("\n🛑 Shutting down MarkanM Bot...")
|
|
163
|
+
finally:
|
|
164
|
+
self._is_running = False
|
|
165
|
+
|
|
166
|
+
def run(self, mode: str = "polling", interval: float = 2.0):
|
|
167
|
+
"""
|
|
168
|
+
Main entry point (alias for run_polling in alpha)
|
|
169
|
+
"""
|
|
170
|
+
if mode == "polling":
|
|
171
|
+
self.run_polling(interval=interval)
|
|
172
|
+
else:
|
|
173
|
+
raise MarkanMError(f"Unsupported run mode '{mode}'. For webhooks, use framework Integration or bot.handle_webhook()")
|
|
174
|
+
|
|
175
|
+
async def _polling_loop(self, interval: float):
|
|
176
|
+
while self._is_running:
|
|
177
|
+
try:
|
|
178
|
+
res = await self.client.post("/polling")
|
|
179
|
+
events_data = res.get("events", [])
|
|
180
|
+
for evt_raw in events_data:
|
|
181
|
+
event = Event(evt_raw)
|
|
182
|
+
await self.dispatch_event(event)
|
|
183
|
+
except MarkanMError as err:
|
|
184
|
+
print(f"⚠️ MarkanM Polling Error: {err.message}")
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
print(f"⚠️ Unexpected Polling Error: {_mask_sensitive(str(exc))}")
|
|
187
|
+
|
|
188
|
+
await asyncio.sleep(interval)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Dict, Any, Optional
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
MarkanMAPIError,
|
|
6
|
+
MarkanMAuthError,
|
|
7
|
+
MarkanMPermissionError,
|
|
8
|
+
MarkanMNotFoundError,
|
|
9
|
+
MarkanMRateLimitError,
|
|
10
|
+
MarkanMError
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import httpx
|
|
15
|
+
HAS_HTTPX = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
HAS_HTTPX = False
|
|
18
|
+
import urllib.request
|
|
19
|
+
import urllib.error
|
|
20
|
+
|
|
21
|
+
class APIClient:
|
|
22
|
+
"""
|
|
23
|
+
Async HTTP Client for MarkanM Bot API v1.
|
|
24
|
+
Uses httpx when installed, with automatic fallback to urllib.request.
|
|
25
|
+
"""
|
|
26
|
+
def __init__(self, token: str, base_url: str = "https://chat.markanm.com/api/bot/v1", timeout: float = 10.0):
|
|
27
|
+
self.token = token
|
|
28
|
+
self.base_url = base_url.rstrip("/")
|
|
29
|
+
self.timeout = timeout
|
|
30
|
+
|
|
31
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
32
|
+
return {
|
|
33
|
+
"Authorization": f"Bearer {self.token}",
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"User-Agent": "markanm-python-sdk/0.1.0a1"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async def request(
|
|
39
|
+
self,
|
|
40
|
+
method: str,
|
|
41
|
+
endpoint: str,
|
|
42
|
+
params: Optional[Dict[str, Any]] = None,
|
|
43
|
+
json_data: Optional[Dict[str, Any]] = None
|
|
44
|
+
) -> Dict[str, Any]:
|
|
45
|
+
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
|
46
|
+
|
|
47
|
+
if params:
|
|
48
|
+
from urllib.parse import urlencode
|
|
49
|
+
url += f"?{urlencode(params)}"
|
|
50
|
+
|
|
51
|
+
if HAS_HTTPX:
|
|
52
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
53
|
+
try:
|
|
54
|
+
response = await client.request(
|
|
55
|
+
method=method.upper(),
|
|
56
|
+
url=url,
|
|
57
|
+
headers=self._get_headers(),
|
|
58
|
+
json=json_data
|
|
59
|
+
)
|
|
60
|
+
status_code = response.status_code
|
|
61
|
+
response_text = response.text
|
|
62
|
+
request_id = response.headers.get("X-MarkanM-Request-ID")
|
|
63
|
+
try:
|
|
64
|
+
res_json = response.json()
|
|
65
|
+
except Exception:
|
|
66
|
+
res_json = {}
|
|
67
|
+
except httpx.TimeoutException:
|
|
68
|
+
raise MarkanMError(f"HTTP Request to {url} timed out after {self.timeout}s")
|
|
69
|
+
except httpx.RequestError as exc:
|
|
70
|
+
raise MarkanMError(f"Network error communicating with MarkanM API: {str(exc)}")
|
|
71
|
+
else:
|
|
72
|
+
# Fallback to urllib.request in executor thread
|
|
73
|
+
def _sync_urllib_request():
|
|
74
|
+
data_bytes = json.dumps(json_data).encode("utf-8") if json_data else None
|
|
75
|
+
req = urllib.request.Request(url, data=data_bytes, headers=self._get_headers(), method=method.upper())
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
78
|
+
body_bytes = resp.read()
|
|
79
|
+
headers_dict = dict(resp.info())
|
|
80
|
+
return resp.status, body_bytes.decode("utf-8"), headers_dict
|
|
81
|
+
except urllib.error.HTTPError as err:
|
|
82
|
+
body_bytes = err.read()
|
|
83
|
+
headers_dict = dict(err.headers)
|
|
84
|
+
return err.code, body_bytes.decode("utf-8"), headers_dict
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise MarkanMError(f"Network error: {str(exc)}")
|
|
87
|
+
|
|
88
|
+
status_code, response_text, resp_headers = await asyncio.to_thread(_sync_urllib_request)
|
|
89
|
+
request_id = resp_headers.get("X-MarkanM-Request-ID") or resp_headers.get("x-markanm-request-id")
|
|
90
|
+
try:
|
|
91
|
+
res_json = json.loads(response_text)
|
|
92
|
+
except Exception:
|
|
93
|
+
res_json = {}
|
|
94
|
+
|
|
95
|
+
if 200 <= status_code < 300:
|
|
96
|
+
return res_json
|
|
97
|
+
|
|
98
|
+
# Parse API Error Payload
|
|
99
|
+
error_data = res_json.get("error", {})
|
|
100
|
+
if isinstance(error_data, dict):
|
|
101
|
+
err_code = error_data.get("code", "api_error")
|
|
102
|
+
err_msg = error_data.get("message", response_text or "API Error")
|
|
103
|
+
else:
|
|
104
|
+
err_code = "api_error"
|
|
105
|
+
err_msg = str(error_data) or response_text
|
|
106
|
+
|
|
107
|
+
if status_code == 401:
|
|
108
|
+
raise MarkanMAuthError(status_code, err_code, err_msg, request_id)
|
|
109
|
+
elif status_code == 403:
|
|
110
|
+
raise MarkanMPermissionError(status_code, err_code, err_msg, request_id)
|
|
111
|
+
elif status_code == 404:
|
|
112
|
+
raise MarkanMNotFoundError(status_code, err_code, err_msg, request_id)
|
|
113
|
+
elif status_code == 429:
|
|
114
|
+
raise MarkanMRateLimitError(status_code, err_code, err_msg, 60, request_id)
|
|
115
|
+
else:
|
|
116
|
+
raise MarkanMAPIError(status_code, err_code, err_msg, request_id)
|
|
117
|
+
|
|
118
|
+
async def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
119
|
+
return await self.request("GET", endpoint, params=params)
|
|
120
|
+
|
|
121
|
+
async def post(self, endpoint: str, json_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
122
|
+
return await self.request("POST", endpoint, json_data=json_data)
|
|
123
|
+
|
|
124
|
+
async def patch(self, endpoint: str, json_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
125
|
+
return await self.request("PATCH", endpoint, json_data=json_data)
|
|
126
|
+
|
|
127
|
+
async def delete(self, endpoint: str) -> Dict[str, Any]:
|
|
128
|
+
return await self.request("DELETE", endpoint)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import Dict, Any, List
|
|
2
|
+
|
|
3
|
+
class UserContext:
|
|
4
|
+
def __init__(self, data: Dict[str, Any]):
|
|
5
|
+
self.id = data.get("id")
|
|
6
|
+
self.username = data.get("username", "")
|
|
7
|
+
self.display_name = data.get("display_name", "")
|
|
8
|
+
self.avatar_url = data.get("avatar_url", "")
|
|
9
|
+
|
|
10
|
+
class RoomContext:
|
|
11
|
+
def __init__(self, data: Dict[str, Any]):
|
|
12
|
+
self.id = data.get("id")
|
|
13
|
+
self.name = data.get("name", "")
|
|
14
|
+
|
|
15
|
+
class MessageContext:
|
|
16
|
+
def __init__(self, data: Dict[str, Any]):
|
|
17
|
+
self.id = data.get("id")
|
|
18
|
+
self.text = data.get("text", "") or data.get("content", "")
|
|
19
|
+
self.type = data.get("type", "text")
|
|
20
|
+
|
|
21
|
+
class CommandContext:
|
|
22
|
+
"""
|
|
23
|
+
Context passed to command handlers: @bot.command("name")
|
|
24
|
+
"""
|
|
25
|
+
def __init__(self, bot, payload: Dict[str, Any]):
|
|
26
|
+
self.bot = bot
|
|
27
|
+
self.raw_data = payload or {}
|
|
28
|
+
self.command = payload.get("command", "").lstrip("/")
|
|
29
|
+
|
|
30
|
+
# Parse command arguments
|
|
31
|
+
raw_text = payload.get("text") or payload.get("message", {}).get("text", "")
|
|
32
|
+
self.text = raw_text
|
|
33
|
+
if "args" in payload and isinstance(payload["args"], list):
|
|
34
|
+
self.args = payload["args"]
|
|
35
|
+
else:
|
|
36
|
+
parts = raw_text.split()
|
|
37
|
+
self.args = parts[1:] if len(parts) > 1 else []
|
|
38
|
+
|
|
39
|
+
self.user = UserContext(payload.get("user") or {})
|
|
40
|
+
self.room = RoomContext(payload.get("room") or {"id": payload.get("conversation_id") or payload.get("room_id")})
|
|
41
|
+
self.message = MessageContext(payload.get("message") or {})
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def room_id(self):
|
|
45
|
+
return self.room.id
|
|
46
|
+
|
|
47
|
+
async def reply(self, content: str):
|
|
48
|
+
"""Reply to room context"""
|
|
49
|
+
if not self.room_id:
|
|
50
|
+
raise ValueError("Cannot reply: missing room ID in context")
|
|
51
|
+
return await self.bot.send_message(self.room_id, content)
|
|
52
|
+
|
|
53
|
+
def __repr__(self) -> str:
|
|
54
|
+
return f"<CommandContext command='{self.command}' args={self.args} room_id={self.room_id}>"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
class Event:
|
|
4
|
+
"""
|
|
5
|
+
MarkanM Platform Event Model
|
|
6
|
+
"""
|
|
7
|
+
def __init__(self, data: Dict[str, Any]):
|
|
8
|
+
self.raw_data = data or {}
|
|
9
|
+
self.id = data.get("event_id") or data.get("id", "")
|
|
10
|
+
self.type = data.get("event_type") or data.get("event", "")
|
|
11
|
+
self.timestamp = data.get("timestamp") or data.get("created_at", "")
|
|
12
|
+
self.payload = data.get("payload") if "payload" in data else data
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def event_id(self) -> str:
|
|
16
|
+
"""Alias for backward compatibility"""
|
|
17
|
+
return self.id
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def event_type(self) -> str:
|
|
21
|
+
"""Alias for backward compatibility"""
|
|
22
|
+
return self.type
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def created_at(self) -> str:
|
|
26
|
+
"""Alias for timestamp"""
|
|
27
|
+
return str(self.timestamp)
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
return f"<MarkanM Event type='{self.type}' id='{self.id}'>"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
def _mask_sensitive(text: str) -> str:
|
|
4
|
+
"""Mask sensitive tokens or keys in strings"""
|
|
5
|
+
if not isinstance(text, str):
|
|
6
|
+
return str(text)
|
|
7
|
+
text = re.sub(r'mkbot_[a-zA-Z0-9_-]+', 'mkbot_***MASKED***', text)
|
|
8
|
+
text = re.sub(r'mkm_sec_[a-zA-Z0-9_-]+', 'mkm_sec_***MASKED***', text)
|
|
9
|
+
text = re.sub(r'whsec_[a-zA-Z0-9_-]+', 'whsec_***MASKED***', text)
|
|
10
|
+
return text
|
|
11
|
+
|
|
12
|
+
class MarkanMError(Exception):
|
|
13
|
+
"""Base exception class for all MarkanM SDK errors"""
|
|
14
|
+
def __init__(self, message: str = "An error occurred with MarkanM SDK"):
|
|
15
|
+
self.message = _mask_sensitive(message)
|
|
16
|
+
super().__init__(self.message)
|
|
17
|
+
|
|
18
|
+
class MarkanMAPIError(MarkanMError):
|
|
19
|
+
"""Raised when MarkanM Bot API returns an HTTP error status"""
|
|
20
|
+
def __init__(self, status_code: int, error_code: str, message: str, request_id: str = None):
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.error_code = error_code
|
|
23
|
+
self.request_id = request_id
|
|
24
|
+
msg = f"HTTP {status_code} [{error_code}]: {message}"
|
|
25
|
+
if request_id:
|
|
26
|
+
msg += f" (Request-ID: {request_id})"
|
|
27
|
+
super().__init__(msg)
|
|
28
|
+
|
|
29
|
+
class MarkanMAuthError(MarkanMAPIError):
|
|
30
|
+
"""Raised on HTTP 401 Unauthorized errors"""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
class MarkanMPermissionError(MarkanMAPIError):
|
|
34
|
+
"""Raised on HTTP 403 Forbidden scope errors"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
class MarkanMNotFoundError(MarkanMAPIError):
|
|
38
|
+
"""Raised on HTTP 404 Not Found errors"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
class MarkanMRateLimitError(MarkanMAPIError):
|
|
42
|
+
"""Raised on HTTP 429 Rate Limit Exceeded errors"""
|
|
43
|
+
def __init__(self, status_code: int, error_code: str, message: str, retry_after: int = 60, request_id: str = None):
|
|
44
|
+
self.retry_after = retry_after
|
|
45
|
+
super().__init__(status_code, error_code, f"{message} (Retry after {retry_after}s)", request_id)
|
|
46
|
+
|
|
47
|
+
class MarkanMWebhookError(MarkanMError):
|
|
48
|
+
"""Raised on HMAC signature or timestamp verification failure"""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
class MarkanMValidationError(MarkanMError):
|
|
52
|
+
"""Raised on SDK validation failure"""
|
|
53
|
+
pass
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
import hashlib
|
|
3
|
+
import time
|
|
4
|
+
import json
|
|
5
|
+
from typing import Dict, Any, Tuple
|
|
6
|
+
from .events import Event
|
|
7
|
+
from .exceptions import MarkanMWebhookError
|
|
8
|
+
|
|
9
|
+
class WebhookHandler:
|
|
10
|
+
"""
|
|
11
|
+
HMAC-SHA256 Webhook Verification & Parsing Helper
|
|
12
|
+
"""
|
|
13
|
+
def __init__(self, secret: str, timestamp_tolerance: int = 300):
|
|
14
|
+
self.secret = secret
|
|
15
|
+
self.timestamp_tolerance = timestamp_tolerance
|
|
16
|
+
|
|
17
|
+
def parse_header(self, signature_header: str) -> Tuple[int, str]:
|
|
18
|
+
"""
|
|
19
|
+
Parse X-MarkanM-Signature header: "t=1788000000,v1=abcdef..."
|
|
20
|
+
"""
|
|
21
|
+
if not signature_header or not isinstance(signature_header, str):
|
|
22
|
+
raise MarkanMWebhookError("Missing or invalid X-MarkanM-Signature header")
|
|
23
|
+
|
|
24
|
+
t_val = None
|
|
25
|
+
v1_val = None
|
|
26
|
+
|
|
27
|
+
for part in signature_header.split(","):
|
|
28
|
+
part = part.strip()
|
|
29
|
+
if "=" in part:
|
|
30
|
+
k, v = part.split("=", 1)
|
|
31
|
+
if k.strip() == "t":
|
|
32
|
+
try:
|
|
33
|
+
t_val = int(v.strip())
|
|
34
|
+
except ValueError:
|
|
35
|
+
raise MarkanMWebhookError("Invalid timestamp in signature header")
|
|
36
|
+
elif k.strip() == "v1":
|
|
37
|
+
v1_val = v.strip()
|
|
38
|
+
|
|
39
|
+
if t_val is None or not v1_val:
|
|
40
|
+
raise MarkanMWebhookError("Malformed X-MarkanM-Signature header. Expected format: 't=TIMESTAMP,v1=SIGNATURE'")
|
|
41
|
+
|
|
42
|
+
return t_val, v1_val
|
|
43
|
+
|
|
44
|
+
def verify(self, payload_body: str, signature_header: str) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Verify HMAC-SHA256 signature and timestamp freshness
|
|
47
|
+
"""
|
|
48
|
+
t_val, expected_sig = self.parse_header(signature_header)
|
|
49
|
+
|
|
50
|
+
# Check timestamp tolerance for replay protection
|
|
51
|
+
current_time = int(time.time())
|
|
52
|
+
if abs(current_time - t_val) > self.timestamp_tolerance:
|
|
53
|
+
raise MarkanMWebhookError(f"Webhook timestamp expired or out of tolerance window ({abs(current_time - t_val)}s > {self.timestamp_tolerance}s)")
|
|
54
|
+
|
|
55
|
+
# Compute HMAC signature
|
|
56
|
+
signed_payload = f"{t_val}.{payload_body}".encode("utf-8")
|
|
57
|
+
computed_sig = hmac.new(
|
|
58
|
+
self.secret.encode("utf-8"),
|
|
59
|
+
signed_payload,
|
|
60
|
+
hashlib.sha256
|
|
61
|
+
).hexdigest()
|
|
62
|
+
|
|
63
|
+
if not hmac.compare_digest(computed_sig, expected_sig):
|
|
64
|
+
raise MarkanMWebhookError("Invalid HMAC-SHA256 webhook signature")
|
|
65
|
+
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def process_event(self, payload_body: str, signature_header: str) -> Event:
|
|
69
|
+
"""
|
|
70
|
+
Verify signature and parse into typed Event model
|
|
71
|
+
"""
|
|
72
|
+
self.verify(payload_body, signature_header)
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads(payload_body)
|
|
75
|
+
except json.JSONDecodeError as exc:
|
|
76
|
+
raise MarkanMWebhookError(f"Invalid JSON payload in webhook body: {str(exc)}")
|
|
77
|
+
|
|
78
|
+
return Event(data)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: markanm
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Official Python SDK for MarkanM Chat Bot Platform & REST API (v0.1.0-alpha)
|
|
5
|
+
Author-email: MarkanM Developer Team <developers@markanm.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 MarkanM Developer Team
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://chat.markanm.com/developers
|
|
29
|
+
Project-URL: Documentation, https://chat.markanm.com/developers/docs
|
|
30
|
+
Project-URL: Repository, https://github.com/markanm/markanm-python-sdk
|
|
31
|
+
Keywords: markanm,bot,chat,sdk,api,oauth,webhooks
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Topic :: Communications :: Chat
|
|
42
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
43
|
+
Requires-Python: >=3.8
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
License-File: LICENSE
|
|
46
|
+
Requires-Dist: httpx>=0.24.0
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
|
|
49
|
+
# MarkanM Official Python SDK (`markanm 0.1.0a1`)
|
|
50
|
+
|
|
51
|
+
Official Python library (v0.1.0-alpha) for building developer bots, automation, and AI agents on **MarkanM Chat** (`https://chat.markanm.com`).
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## ⚠️ Alpha Release Notice
|
|
56
|
+
|
|
57
|
+
This is **v0.1.0-alpha** (`markanm 0.1.0a1`). It is intended for testing against the MarkanM Bot API v1.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 🚀 Installation
|
|
62
|
+
|
|
63
|
+
Install via `pip`:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install markanm
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## ⚡ Quick Start
|
|
72
|
+
|
|
73
|
+
### 1. Simple Hello Bot (`hello_bot.py`)
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import os
|
|
77
|
+
from markanm import Bot
|
|
78
|
+
|
|
79
|
+
bot = Bot(os.environ["MARKANM_BOT_TOKEN"])
|
|
80
|
+
|
|
81
|
+
@bot.command("hello")
|
|
82
|
+
async def hello(ctx):
|
|
83
|
+
await ctx.reply("Hello 👋 Welcome to MarkanM!")
|
|
84
|
+
|
|
85
|
+
@bot.command("start")
|
|
86
|
+
async def start(ctx):
|
|
87
|
+
await ctx.reply("🚀 Bot activated and ready!")
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
bot.run_polling()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
### 2. Event Listeners
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
@bot.on("message.created")
|
|
99
|
+
async def on_message(event):
|
|
100
|
+
print(f"New message received: {event.payload}")
|
|
101
|
+
|
|
102
|
+
@bot.on("member.joined")
|
|
103
|
+
def on_member_joined(event):
|
|
104
|
+
print(f"User joined room: {event.payload.get('user')}")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
### 3. AI Assistant Bot (`ai_bot.py`)
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
import os
|
|
113
|
+
from markanm import Bot
|
|
114
|
+
from markanm.ai import AI
|
|
115
|
+
|
|
116
|
+
bot = Bot(os.environ["MARKANM_BOT_TOKEN"])
|
|
117
|
+
ai = AI(provider="openai", api_key=os.environ["OPENAI_API_KEY"])
|
|
118
|
+
|
|
119
|
+
@bot.command("ask")
|
|
120
|
+
async def ask(ctx):
|
|
121
|
+
prompt = " ".join(ctx.args)
|
|
122
|
+
if not prompt:
|
|
123
|
+
await ctx.reply("Usage: /ask <question>")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
answer = await ai.generate(prompt)
|
|
127
|
+
await ctx.reply(answer)
|
|
128
|
+
|
|
129
|
+
bot.run_polling()
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
### 4. Webhook Verification (Flask / FastAPI)
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from markanm import WebhookHandler, MarkanMWebhookError
|
|
138
|
+
|
|
139
|
+
handler = WebhookHandler(secret="whsec_YOUR_SECRET", timestamp_tolerance=300)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
# Verifies HMAC-SHA256 signature header and 300s timestamp tolerance
|
|
143
|
+
event = handler.process_event(request_body_text, request_headers.get("X-MarkanM-Signature"))
|
|
144
|
+
print(f"Event verified: {event.id} ({event.type})")
|
|
145
|
+
except MarkanMWebhookError as err:
|
|
146
|
+
print(f"Webhook error: {err}")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 🧪 Testing
|
|
152
|
+
|
|
153
|
+
Run tests with `pytest`:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pytest sdk/python/tests/
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 📚 API Reference
|
|
162
|
+
For complete API endpoints and permission scope guidelines, visit:
|
|
163
|
+
**https://chat.markanm.com/developers/docs**
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.py
|
|
5
|
+
src/markanm/__init__.py
|
|
6
|
+
src/markanm/ai.py
|
|
7
|
+
src/markanm/bot.py
|
|
8
|
+
src/markanm/client.py
|
|
9
|
+
src/markanm/context.py
|
|
10
|
+
src/markanm/events.py
|
|
11
|
+
src/markanm/exceptions.py
|
|
12
|
+
src/markanm/webhook.py
|
|
13
|
+
src/markanm.egg-info/PKG-INFO
|
|
14
|
+
src/markanm.egg-info/SOURCES.txt
|
|
15
|
+
src/markanm.egg-info/dependency_links.txt
|
|
16
|
+
src/markanm.egg-info/requires.txt
|
|
17
|
+
src/markanm.egg-info/top_level.txt
|
|
18
|
+
tests/test_ai.py
|
|
19
|
+
tests/test_bot.py
|
|
20
|
+
tests/test_context.py
|
|
21
|
+
tests/test_events.py
|
|
22
|
+
tests/test_webhook.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.24.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
markanm
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from markanm.ai import AI
|
|
3
|
+
from markanm.exceptions import MarkanMError
|
|
4
|
+
|
|
5
|
+
async def test_unsupported_ai_provider():
|
|
6
|
+
ai = AI(provider="unsupported_provider", api_key="test_key")
|
|
7
|
+
caught = False
|
|
8
|
+
try:
|
|
9
|
+
await ai.generate("Hello")
|
|
10
|
+
except MarkanMError as exc:
|
|
11
|
+
caught = True
|
|
12
|
+
assert "Unsupported AI provider" in str(exc)
|
|
13
|
+
|
|
14
|
+
assert caught is True, "Expected MarkanMError for unsupported AI provider"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from markanm.bot import Bot
|
|
3
|
+
from markanm.events import Event
|
|
4
|
+
|
|
5
|
+
async def test_bot_command_and_event_dispatching():
|
|
6
|
+
bot = Bot("mkbot_test_token_123456789")
|
|
7
|
+
|
|
8
|
+
executed_command = False
|
|
9
|
+
executed_listener = False
|
|
10
|
+
|
|
11
|
+
@bot.command("start")
|
|
12
|
+
async def handle_start(ctx):
|
|
13
|
+
nonlocal executed_command
|
|
14
|
+
executed_command = True
|
|
15
|
+
assert ctx.command == "start"
|
|
16
|
+
|
|
17
|
+
@bot.on("message.created")
|
|
18
|
+
def handle_msg(evt):
|
|
19
|
+
nonlocal executed_listener
|
|
20
|
+
executed_listener = True
|
|
21
|
+
assert evt.type == "message.created"
|
|
22
|
+
|
|
23
|
+
# Dispatch command event
|
|
24
|
+
cmd_event = Event({
|
|
25
|
+
"event_id": "evt_cmd",
|
|
26
|
+
"event_type": "command.received",
|
|
27
|
+
"payload": {"command": "/start", "user": {"username": "alex"}, "room": {"id": 10}}
|
|
28
|
+
})
|
|
29
|
+
await bot.dispatch_event(cmd_event)
|
|
30
|
+
assert executed_command is True
|
|
31
|
+
|
|
32
|
+
# Dispatch message event
|
|
33
|
+
msg_event = Event({
|
|
34
|
+
"event_id": "evt_msg",
|
|
35
|
+
"event_type": "message.created",
|
|
36
|
+
"payload": {"text": "hello"}
|
|
37
|
+
})
|
|
38
|
+
await bot.dispatch_event(msg_event)
|
|
39
|
+
assert executed_listener is True
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from markanm.context import CommandContext
|
|
2
|
+
|
|
3
|
+
def test_command_context_parsing():
|
|
4
|
+
payload = {
|
|
5
|
+
"command": "/ask",
|
|
6
|
+
"text": "/ask What is Python?",
|
|
7
|
+
"user": {"id": 1, "username": "alex", "display_name": "Alex"},
|
|
8
|
+
"room": {"id": 42, "name": "General Room"}
|
|
9
|
+
}
|
|
10
|
+
ctx = CommandContext(None, payload)
|
|
11
|
+
assert ctx.command == "ask"
|
|
12
|
+
assert ctx.args == ["What", "is", "Python?"]
|
|
13
|
+
assert ctx.user.username == "alex"
|
|
14
|
+
assert ctx.room.id == 42
|
|
15
|
+
assert ctx.room_id == 42
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from markanm.events import Event
|
|
2
|
+
|
|
3
|
+
def test_event_properties():
|
|
4
|
+
raw_data = {
|
|
5
|
+
"event_id": "evt_12345",
|
|
6
|
+
"event_type": "message.created",
|
|
7
|
+
"timestamp": 1788000000,
|
|
8
|
+
"payload": {"text": "hello"}
|
|
9
|
+
}
|
|
10
|
+
evt = Event(raw_data)
|
|
11
|
+
assert evt.id == "evt_12345"
|
|
12
|
+
assert evt.event_id == "evt_12345"
|
|
13
|
+
assert evt.type == "message.created"
|
|
14
|
+
assert evt.event_type == "message.created"
|
|
15
|
+
assert evt.payload == {"text": "hello"}
|
|
16
|
+
assert repr(evt) == "<MarkanM Event type='message.created' id='evt_12345'>"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
import hashlib
|
|
3
|
+
import time
|
|
4
|
+
from markanm.webhook import WebhookHandler
|
|
5
|
+
from markanm.exceptions import MarkanMWebhookError
|
|
6
|
+
|
|
7
|
+
def test_webhook_verification():
|
|
8
|
+
secret = "whsec_test_secret_12345"
|
|
9
|
+
handler = WebhookHandler(secret=secret, timestamp_tolerance=300)
|
|
10
|
+
|
|
11
|
+
timestamp = int(time.time())
|
|
12
|
+
body = '{"event_id":"evt_1","event_type":"message.created"}'
|
|
13
|
+
signed_payload = f"{timestamp}.{body}".encode("utf-8")
|
|
14
|
+
sig = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
|
|
15
|
+
header = f"t={timestamp},v1={sig}"
|
|
16
|
+
|
|
17
|
+
# Valid signature check
|
|
18
|
+
assert handler.verify(body, header) is True
|
|
19
|
+
|
|
20
|
+
# Process event check
|
|
21
|
+
event = handler.process_event(body, header)
|
|
22
|
+
assert event.id == "evt_1"
|
|
23
|
+
assert event.type == "message.created"
|
|
24
|
+
|
|
25
|
+
def test_expired_webhook():
|
|
26
|
+
secret = "whsec_test_secret_12345"
|
|
27
|
+
handler = WebhookHandler(secret=secret, timestamp_tolerance=10)
|
|
28
|
+
|
|
29
|
+
expired_timestamp = int(time.time()) - 500 # 500 seconds old
|
|
30
|
+
body = '{"event_id":"evt_1"}'
|
|
31
|
+
signed_payload = f"{expired_timestamp}.{body}".encode("utf-8")
|
|
32
|
+
sig = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
|
|
33
|
+
header = f"t={expired_timestamp},v1={sig}"
|
|
34
|
+
|
|
35
|
+
caught = False
|
|
36
|
+
try:
|
|
37
|
+
handler.verify(body, header)
|
|
38
|
+
except MarkanMWebhookError as exc:
|
|
39
|
+
caught = True
|
|
40
|
+
assert "timestamp expired" in str(exc)
|
|
41
|
+
|
|
42
|
+
assert caught is True, "Expected MarkanMWebhookError for expired timestamp"
|