Wajahat698 commited on
Commit
af03e6c
Β·
verified Β·
1 Parent(s): cc2746a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +83 -100
app.py CHANGED
@@ -3,6 +3,7 @@ import requests
3
  import base64
4
  import logging
5
  import os
 
6
 
7
  app = Flask(__name__)
8
  logging.basicConfig(level=logging.INFO)
@@ -16,15 +17,9 @@ CW_PRIVATE_KEY = os.environ.get("CW_PRIVATE_KEY", "kOwo89oUO6SVVSYi")
16
  CW_CLIENT_ID = os.environ.get("CW_CLIENT_ID", "e45cf35f-24a8-4f5f-b47a-19376cafce64")
17
  BASE_URL = "https://api-na.myconnectwise.net/v4_6_release/apis/3.0"
18
 
19
- # ── HARDCODED FALLBACKS ────────────────────────────────────────────────────────
20
- # Set these as Hugging Face Secrets once you find the real values in ConnectWise.
21
- # Log into ConnectWise manually and check:
22
- # CW_BOARD_ID β†’ Service Desk β†’ Boards β†’ open your board β†’ ID is in the URL
23
- # CW_COMPANY_NUM_ID→ Companies → your company → ID in URL
24
- # CW_PRIORITY_ID β†’ Service Desk β†’ Priority List β†’ ID in URL
25
- CW_BOARD_ID = os.environ.get("CW_BOARD_ID", "") # e.g. "1"
26
- CW_COMPANY_NUM_ID = os.environ.get("CW_COMPANY_NUM_ID", "") # e.g. "250"
27
- CW_PRIORITY_ID = os.environ.get("CW_PRIORITY_ID", "") # e.g. "8"
28
 
29
  auth = f"{CW_COMPANY_ID}+{CW_PUBLIC_KEY}:{CW_PRIVATE_KEY}"
30
  encoded_auth = base64.b64encode(auth.encode()).decode()
@@ -35,45 +30,58 @@ CW_HEADERS = {
35
  "Content-Type": "application/json",
36
  }
37
 
 
 
 
 
38
 
39
- # ── Discovery (works only once 403 is fixed) ───────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  def discover():
42
- """Try to pull board/company/priority IDs from the API."""
43
  board_id = company_id = priority_id = None
44
  board_name = "default"
45
 
46
  try:
47
  r = requests.get(f"{BASE_URL}/service/boards", headers=CW_HEADERS,
48
  params={"pageSize": 100}, timeout=10)
49
- logger.info("Boards %s: %s", r.status_code, r.text[:400])
50
  if r.status_code == 200 and r.json():
51
  b = r.json()[0]
52
  board_id, board_name = b["id"], b["name"]
 
53
  except Exception as e:
54
  logger.warning("Board discover error: %s", e)
55
 
56
  try:
57
  r = requests.get(f"{BASE_URL}/company/companies", headers=CW_HEADERS,
58
  params={"pageSize": 50}, timeout=10)
59
- logger.info("Companies %s: %s", r.status_code, r.text[:400])
60
  if r.status_code == 200 and r.json():
61
- # Try to match our company name, else take first
62
- all_companies = r.json()
63
  match = next(
64
- (c for c in all_companies
65
  if CW_COMPANY_ID.lower() in (c.get("name","") + c.get("identifier","")).lower()),
66
- all_companies[0]
67
  )
68
  company_id = match["id"]
69
- logger.info("Matched company: %s id=%s", match.get("name"), company_id)
70
  except Exception as e:
71
  logger.warning("Company discover error: %s", e)
72
 
73
  try:
74
  r = requests.get(f"{BASE_URL}/service/priorities", headers=CW_HEADERS,
75
  params={"pageSize": 50}, timeout=10)
76
- logger.info("Priorities %s: %s", r.status_code, r.text[:300])
77
  if r.status_code == 200 and r.json():
78
  priority_id = r.json()[0]["id"]
79
  except Exception as e:
@@ -85,33 +93,24 @@ def discover():
85
  # ── Ticket creation ────────────────────────────────────────────────────────────
86
 
87
  def post_ticket(payload):
88
- r = requests.post(
89
- f"{BASE_URL}/service/tickets",
90
- headers=CW_HEADERS,
91
- json=payload,
92
- timeout=10,
93
- )
94
- logger.info("CW POST %s: %s", r.status_code, r.text[:500])
95
  return r
96
 
97
 
98
  def create_ticket(issue):
99
  summary = issue[:100]
100
 
101
- # Use hardcoded env var IDs if set, otherwise try discovery
102
  if CW_BOARD_ID and CW_COMPANY_NUM_ID:
103
  board_id = int(CW_BOARD_ID)
104
  company_id = int(CW_COMPANY_NUM_ID)
105
  priority_id = int(CW_PRIORITY_ID) if CW_PRIORITY_ID else None
106
  board_name = "configured"
107
- logger.info("Using env var IDs: board=%s company=%s priority=%s",
108
- board_id, company_id, priority_id)
109
  else:
110
  board_id, board_name, company_id, priority_id = discover()
111
 
112
- attempts = []
113
-
114
- # ── 1. Numeric IDs (most reliable) ───────────────────────────────────────
115
  if board_id and company_id:
116
  payload = {
117
  "summary": summary, "initialDescription": issue,
@@ -123,51 +122,31 @@ def create_ticket(issue):
123
  r = post_ticket(payload)
124
  if r.status_code in (200, 201):
125
  return r.json().get("id"), board_name
126
- attempts.append(r.text)
127
 
128
- # ── 2. Company numeric ID only (no board) ────────────────────────────────
129
  if company_id:
130
- payload = {
131
- "summary": summary, "initialDescription": issue,
132
- "company": {"id": company_id},
133
- }
134
- r = post_ticket(payload)
135
  if r.status_code in (200, 201):
136
  return r.json().get("id"), "default"
137
- attempts.append(r.text)
138
 
139
- # ── 3. Member's own company as the company (common CW pattern) ───────────
140
- # When company = the MSP itself, CW sometimes needs companyType or contactId
141
- # Try with just the member company ID from login
142
  try:
143
- r2 = requests.get(
144
- f"{BASE_URL}/system/members/me",
145
- headers=CW_HEADERS, timeout=10
146
- )
147
  if r2.status_code == 200:
148
- me = r2.json()
149
- my_company_id = me.get("company", {}).get("id")
150
- logger.info("Member company id: %s", my_company_id)
151
- if my_company_id:
152
- payload = {
153
- "summary": summary, "initialDescription": issue,
154
- "company": {"id": my_company_id},
155
- }
156
  if board_id:
157
  payload["board"] = {"id": board_id}
158
  r = post_ticket(payload)
159
  if r.status_code in (200, 201):
160
  return r.json().get("id"), board_name or "default"
161
- attempts.append(r.text)
162
  except Exception as e:
163
  logger.warning("Member lookup failed: %s", e)
164
 
165
- # ── 4. Absolute minimum ───────────────────────────────────────────────────
166
- r = post_ticket({"summary": summary, "initialDescription": issue})
167
- if r.status_code in (200, 201):
168
- return r.json().get("id"), "default"
169
-
170
- logger.error("All attempts failed. Last errors: %s", attempts[-3:])
171
  return None, None
172
 
173
 
@@ -183,6 +162,36 @@ def send_slack(channel, text):
183
  )
184
 
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  # ── Routes ─────────────────────────────────────────────────────────────────────
187
 
188
  @app.route("/")
@@ -192,15 +201,6 @@ def home():
192
 
193
  @app.route("/debug/cw")
194
  def debug_cw():
195
- """
196
- Visit /debug/cw to see what your API key CAN access.
197
- If you see 403 errors here, your API key needs more permissions in ConnectWise.
198
- Go to: System β†’ Members β†’ API Members β†’ Security Role β†’ enable Inquire on:
199
- Companies > Company Maintenance
200
- Service Desk > Service Boards
201
- Service Desk > Service Tickets (Add + Inquire)
202
- Service Desk > Priorities
203
- """
204
  out = {
205
  "env_vars": {
206
  "CW_BOARD_ID": CW_BOARD_ID or "NOT SET",
@@ -208,19 +208,17 @@ def debug_cw():
208
  "CW_PRIORITY_ID": CW_PRIORITY_ID or "NOT SET",
209
  }
210
  }
211
-
212
  for label, url, params in [
213
- ("boards", f"{BASE_URL}/service/boards", {"pageSize": 50}),
214
- ("companies", f"{BASE_URL}/company/companies", {"pageSize": 20}),
215
- ("priorities", f"{BASE_URL}/service/priorities", {"pageSize": 20}),
216
- ("member_me", f"{BASE_URL}/system/members/me", {}),
217
  ]:
218
  try:
219
  r = requests.get(url, headers=CW_HEADERS, params=params, timeout=10)
220
  out[label] = {"status": r.status_code, "data": r.json()}
221
  except Exception as e:
222
  out[label] = {"error": str(e)}
223
-
224
  return jsonify(out)
225
 
226
 
@@ -228,34 +226,19 @@ def debug_cw():
228
  def slack_events():
229
  data = request.get_json()
230
 
 
231
  if data.get("type") == "url_verification":
232
  return jsonify({"challenge": data.get("challenge")})
233
 
234
- event = data.get("event", {})
235
-
236
- if event.get("bot_id") or event.get("subtype") == "bot_message":
 
237
  return "", 200
238
 
239
- if event.get("type") == "message":
240
- text = event.get("text", "")
241
- channel = event.get("channel")
242
- text = text.replace("<@U0AL3KRRELE>", "").strip()
243
-
244
- if not text:
245
- return "", 200
246
-
247
- ticket_id, board_used = create_ticket(text)
248
-
249
- if ticket_id:
250
- reply = f"βœ… Ticket created!\n🎫 ID: `{ticket_id}`\nπŸ“‹ Board: {board_used}"
251
- else:
252
- reply = (
253
- "⚠️ Could not create ticket. Your API key needs more permissions in ConnectWise.\n"
254
- "Go to: System β†’ Members β†’ API Members β†’ Security Role\n"
255
- "Enable Inquire on: Company Maintenance, Service Boards, Service Tickets, Priorities"
256
- )
257
-
258
- send_slack(channel, reply)
259
 
260
  return "", 200
261
 
 
3
  import base64
4
  import logging
5
  import os
6
+ import threading
7
 
8
  app = Flask(__name__)
9
  logging.basicConfig(level=logging.INFO)
 
17
  CW_CLIENT_ID = os.environ.get("CW_CLIENT_ID", "e45cf35f-24a8-4f5f-b47a-19376cafce64")
18
  BASE_URL = "https://api-na.myconnectwise.net/v4_6_release/apis/3.0"
19
 
20
+ CW_BOARD_ID = os.environ.get("CW_BOARD_ID", "")
21
+ CW_COMPANY_NUM_ID = os.environ.get("CW_COMPANY_NUM_ID", "")
22
+ CW_PRIORITY_ID = os.environ.get("CW_PRIORITY_ID", "")
 
 
 
 
 
 
23
 
24
  auth = f"{CW_COMPANY_ID}+{CW_PUBLIC_KEY}:{CW_PRIVATE_KEY}"
25
  encoded_auth = base64.b64encode(auth.encode()).decode()
 
30
  "Content-Type": "application/json",
31
  }
32
 
33
+ # ── Deduplication: track already-processed Slack event IDs ────────────────────
34
+ # Stores the last 500 event IDs to prevent duplicate processing
35
+ processed_events = set()
36
+ processed_lock = threading.Lock()
37
 
38
+ def already_seen(event_id):
39
+ """Returns True if we've already processed this event. Thread-safe."""
40
+ with processed_lock:
41
+ if event_id in processed_events:
42
+ return True
43
+ processed_events.add(event_id)
44
+ # Keep the set from growing forever
45
+ if len(processed_events) > 500:
46
+ oldest = next(iter(processed_events))
47
+ processed_events.discard(oldest)
48
+ return False
49
+
50
+
51
+ # ── Discovery ──────────────────────────────────────────────────────────────────
52
 
53
  def discover():
 
54
  board_id = company_id = priority_id = None
55
  board_name = "default"
56
 
57
  try:
58
  r = requests.get(f"{BASE_URL}/service/boards", headers=CW_HEADERS,
59
  params={"pageSize": 100}, timeout=10)
 
60
  if r.status_code == 200 and r.json():
61
  b = r.json()[0]
62
  board_id, board_name = b["id"], b["name"]
63
+ logger.info("Discovered board: %s id=%s", board_name, board_id)
64
  except Exception as e:
65
  logger.warning("Board discover error: %s", e)
66
 
67
  try:
68
  r = requests.get(f"{BASE_URL}/company/companies", headers=CW_HEADERS,
69
  params={"pageSize": 50}, timeout=10)
 
70
  if r.status_code == 200 and r.json():
71
+ all_cos = r.json()
 
72
  match = next(
73
+ (c for c in all_cos
74
  if CW_COMPANY_ID.lower() in (c.get("name","") + c.get("identifier","")).lower()),
75
+ all_cos[0]
76
  )
77
  company_id = match["id"]
78
+ logger.info("Discovered company: %s id=%s", match.get("name"), company_id)
79
  except Exception as e:
80
  logger.warning("Company discover error: %s", e)
81
 
82
  try:
83
  r = requests.get(f"{BASE_URL}/service/priorities", headers=CW_HEADERS,
84
  params={"pageSize": 50}, timeout=10)
 
85
  if r.status_code == 200 and r.json():
86
  priority_id = r.json()[0]["id"]
87
  except Exception as e:
 
93
  # ── Ticket creation ────────────────────────────────────────────────────────────
94
 
95
  def post_ticket(payload):
96
+ r = requests.post(f"{BASE_URL}/service/tickets",
97
+ headers=CW_HEADERS, json=payload, timeout=10)
98
+ logger.info("CW POST %s: %s", r.status_code, r.text[:400])
 
 
 
 
99
  return r
100
 
101
 
102
  def create_ticket(issue):
103
  summary = issue[:100]
104
 
 
105
  if CW_BOARD_ID and CW_COMPANY_NUM_ID:
106
  board_id = int(CW_BOARD_ID)
107
  company_id = int(CW_COMPANY_NUM_ID)
108
  priority_id = int(CW_PRIORITY_ID) if CW_PRIORITY_ID else None
109
  board_name = "configured"
 
 
110
  else:
111
  board_id, board_name, company_id, priority_id = discover()
112
 
113
+ # Attempt 1: full numeric IDs
 
 
114
  if board_id and company_id:
115
  payload = {
116
  "summary": summary, "initialDescription": issue,
 
122
  r = post_ticket(payload)
123
  if r.status_code in (200, 201):
124
  return r.json().get("id"), board_name
 
125
 
126
+ # Attempt 2: company only
127
  if company_id:
128
+ r = post_ticket({"summary": summary, "initialDescription": issue,
129
+ "company": {"id": company_id}})
 
 
 
130
  if r.status_code in (200, 201):
131
  return r.json().get("id"), "default"
 
132
 
133
+ # Attempt 3: use member's own company ID
 
 
134
  try:
135
+ r2 = requests.get(f"{BASE_URL}/system/members/me", headers=CW_HEADERS, timeout=10)
 
 
 
136
  if r2.status_code == 200:
137
+ my_cid = r2.json().get("company", {}).get("id")
138
+ if my_cid:
139
+ payload = {"summary": summary, "initialDescription": issue,
140
+ "company": {"id": my_cid}}
 
 
 
 
141
  if board_id:
142
  payload["board"] = {"id": board_id}
143
  r = post_ticket(payload)
144
  if r.status_code in (200, 201):
145
  return r.json().get("id"), board_name or "default"
 
146
  except Exception as e:
147
  logger.warning("Member lookup failed: %s", e)
148
 
149
+ logger.error("All ticket attempts failed")
 
 
 
 
 
150
  return None, None
151
 
152
 
 
162
  )
163
 
164
 
165
+ def handle_event(event_data):
166
+ """Run in background thread so Slack gets 200 instantly."""
167
+ event = event_data.get("event", {})
168
+
169
+ if event.get("bot_id") or event.get("subtype") == "bot_message":
170
+ return
171
+
172
+ if event.get("type") != "message":
173
+ return
174
+
175
+ text = event.get("text", "")
176
+ channel = event.get("channel")
177
+ text = text.replace("<@U0AL3KRRELE>", "").strip()
178
+
179
+ if not text:
180
+ return
181
+
182
+ ticket_id, board_used = create_ticket(text)
183
+
184
+ if ticket_id:
185
+ reply = f"βœ… Ticket created!\n🎫 ID: `{ticket_id}`\nπŸ“‹ Board: {board_used}"
186
+ else:
187
+ reply = (
188
+ "⚠️ Could not create ConnectWise ticket.\n"
189
+ "Visit /debug/cw to check API permissions."
190
+ )
191
+
192
+ send_slack(channel, reply)
193
+
194
+
195
  # ── Routes ─────────────────────────────────────────────────────────────────────
196
 
197
  @app.route("/")
 
201
 
202
  @app.route("/debug/cw")
203
  def debug_cw():
 
 
 
 
 
 
 
 
 
204
  out = {
205
  "env_vars": {
206
  "CW_BOARD_ID": CW_BOARD_ID or "NOT SET",
 
208
  "CW_PRIORITY_ID": CW_PRIORITY_ID or "NOT SET",
209
  }
210
  }
 
211
  for label, url, params in [
212
+ ("boards", f"{BASE_URL}/service/boards", {"pageSize": 50}),
213
+ ("companies", f"{BASE_URL}/company/companies", {"pageSize": 20}),
214
+ ("priorities", f"{BASE_URL}/service/priorities", {"pageSize": 20}),
215
+ ("member_me", f"{BASE_URL}/system/members/me", {}),
216
  ]:
217
  try:
218
  r = requests.get(url, headers=CW_HEADERS, params=params, timeout=10)
219
  out[label] = {"status": r.status_code, "data": r.json()}
220
  except Exception as e:
221
  out[label] = {"error": str(e)}
 
222
  return jsonify(out)
223
 
224
 
 
226
  def slack_events():
227
  data = request.get_json()
228
 
229
+ # URL verification handshake
230
  if data.get("type") == "url_verification":
231
  return jsonify({"challenge": data.get("challenge")})
232
 
233
+ # ── Deduplicate: drop retries of the same event ────────────────────────
234
+ event_id = data.get("event_id", "")
235
+ if event_id and already_seen(event_id):
236
+ logger.info("Duplicate event %s β€” ignored", event_id)
237
  return "", 200
238
 
239
+ # ── Return 200 immediately so Slack stops retrying ─────────────────────
240
+ thread = threading.Thread(target=handle_event, args=(data,), daemon=True)
241
+ thread.start()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
243
  return "", 200
244